feat: 添加占位图支持,优化 Img 和 Video 组件,更新 OTA 升级逻辑

This commit is contained in:
康猛 2026-01-23 14:21:40 +08:00
parent 4268f1a905
commit 14c84de1ce
9 changed files with 143 additions and 51 deletions

View File

@ -1,5 +1,6 @@
import { Image as ExpoImage, type ImageProps as ExpoImageProps } from 'expo-image' import { Image as ExpoImage, type ImageProps as ExpoImageProps } from 'expo-image'
import React, { forwardRef, memo, useMemo } from 'react' import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react'
import { View } from 'react-native'
import tw from 'twrnc' import tw from 'twrnc'
interface ImgProps extends ExpoImageProps { interface ImgProps extends ExpoImageProps {
@ -11,6 +12,8 @@ interface ImgProps extends ExpoImageProps {
isCompression?: boolean isCompression?: boolean
/** 自定义缓存键,用于需要重定向的 URL */ /** 自定义缓存键,用于需要重定向的 URL */
cacheKey?: string cacheKey?: string
/** 占位图 URL在真实图片加载完成前显示 */
placeholderSrc?: string
} }
// ExpoImage.clearDiskCache() // ExpoImage.clearDiskCache()
@ -24,15 +27,22 @@ const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
className = '', className = '',
style = {}, style = {},
src, src,
errorSource,
source: propSource, source: propSource,
cacheKey, cacheKey,
width = 256, width = 256,
isCompression = false, isCompression = false,
isWebP = true, isWebP = true,
placeholderSrc,
onLoad,
...reset ...reset
} = props } = props
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
setIsLoaded(false)
}, [src])
const imageStyle = tw`${className}` const imageStyle = tw`${className}`
// 静态图全部压缩jpg // 静态图全部压缩jpg
@ -49,47 +59,77 @@ const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
// 构建图片源 // 构建图片源
const imageSource = useMemo(() => { const imageSource = useMemo(() => {
// 如果提供了source属性优先使用
if (propSource) return propSource if (propSource) return propSource
if (!src) return undefined if (!src) return undefined
if (typeof src === 'number') { if (typeof src === 'number') {
// 本地图片资源require导入的资源ID
return src return src
} else { } else {
// 网络图片或本地文件路径
if (isNetworkImage(src)) { if (isNetworkImage(src)) {
const finalUrl = isCompression ? compressionUrl : src const finalUrl = isCompression ? compressionUrl : src
// console.log('finalUrl-------------', finalUrl)
return { return {
uri: finalUrl, uri: finalUrl,
cacheKey: cacheKey || finalUrl, cacheKey: cacheKey || finalUrl,
} }
} else { } else {
// 本地文件路径
return { uri: src } return { uri: src }
} }
} }
}, [src, propSource, cacheKey, isCompression, compressionUrl]) }, [src, propSource, cacheKey, isCompression, compressionUrl])
const imgProps = { const handleLoad = (e: any) => {
style: [style, imageStyle], console.log('handleLoad--------------', e)
ref,
source: imageSource, setIsLoaded(true)
// 使用 disk 缓存策略,减少内存占用 onLoad?.(e)
cachePolicy: 'disk' as const,
// 添加内存缓存上限,当内存紧张时优先释放
recyclingKey: typeof src === 'string' ? src : undefined,
errorSource,
transition: { duration: 200, effect: 'cross-dissolve' as const },
...reset,
} }
return <ExpoImage {...imgProps} /> // 无占位图时直接返回原图
if (!placeholderSrc) {
return (
<ExpoImage
ref={ref}
style={[style, imageStyle]}
source={imageSource}
cachePolicy="disk"
recyclingKey={typeof src === 'string' ? src : undefined}
transition={{ duration: 200, effect: 'cross-dissolve' }}
onLoad={onLoad}
{...reset}
/>
)
}
// return <Image style={[style, imageStyle]} source={{ uri: compressionUrl(src as string) }} /> const showPlaceholder = placeholderSrc
// 有占位图时使用层叠布局
return (
<View style={[style, imageStyle, { overflow: 'hidden' }]}>
{/* 占位图层 - 加载完成后隐藏 */}
{showPlaceholder && (
<ExpoImage
style={{ position: 'absolute', width: '100%', height: '100%' }}
source={{ uri: placeholderSrc }}
cachePolicy="disk"
contentFit={reset.contentFit || 'cover'}
transition={{ duration: 100, effect: 'curl-up' }}
/>
)}
{/* 真实图片层 */}
<ExpoImage
ref={ref}
style={{ width: '100%', height: '100%', opacity: isLoaded ? 1 : 0 }}
source={imageSource}
cachePolicy="disk"
recyclingKey={typeof src === 'string' ? src : undefined}
transition={{ duration: 300, effect: 'cross-dissolve' }}
onLoad={handleLoad}
contentFit={reset.contentFit || 'cover'}
{...reset}
/>
</View>
)
}) })
Img.displayName = 'Img' Img.displayName = 'Img'

View File

@ -1,12 +1,15 @@
import { Image, type ImageRef } from 'expo-image' import { Image as ExpoImage, type ImageRef } from 'expo-image'
import { memo, useEffect, useRef, useState } from 'react' import { memo, useEffect, useRef, useState } from 'react'
import { type ViewStyle } from 'react-native' import { View, type ViewStyle } from 'react-native'
import Video from 'react-native-video' import Video from 'react-native-video'
import tw from 'twrnc'
import { videoUrlCache } from '@/utils/storage' import { videoUrlCache } from '@/utils/storage'
type Props = { type Props = {
className?: string
url?: string url?: string
placeholderUrl?: string
needWeb?: boolean needWeb?: boolean
style?: ViewStyle style?: ViewStyle
width?: number width?: number
@ -14,7 +17,16 @@ type Props = {
} & React.ComponentProps<typeof Video> } & React.ComponentProps<typeof Video>
// 默认宽度256半屏宽度 // 默认宽度256半屏宽度
const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ...videoProps }: Props) => { const VideoBox = ({
className = '',
url,
placeholderUrl = '',
needWeb = true,
width = 256,
style,
autoplay = true,
...videoProps
}: Props) => {
const [urlFinal, setUrlFinal] = useState('') const [urlFinal, setUrlFinal] = useState('')
const imageRef = useRef<ImageRef | null>(null) const imageRef = useRef<ImageRef | null>(null)
@ -114,7 +126,7 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
) )
} }
return ( return (
<Image <ExpoImage
cachePolicy="disk" cachePolicy="disk"
source={{ uri: url }} source={{ uri: url }}
style={style as any} style={style as any}
@ -123,9 +135,25 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
) )
} }
const imageStyle = tw`${className}`
const showPlaceholder = !!placeholderUrl
return ( return (
// 移除 key 避免组件重建导致闪烁,使用 transition 实现平滑切换 <View style={[style, imageStyle, { overflow: 'hidden' }]}>
<Image {/* 占位图层 - 加载完成后隐藏 */}
{showPlaceholder && (
<ExpoImage
style={{ position: 'absolute', width: '100%', height: '100%' }}
source={{ uri: placeholderUrl }}
cachePolicy="disk"
contentFit={'cover'}
transition={{ duration: 100, effect: 'curl-up' }}
/>
)}
{/* 真实图片层 */}
<ExpoImage
ref={imageRef} ref={imageRef}
// 只使用 disk 缓存,减少内存占用 // 只使用 disk 缓存,减少内存占用
cachePolicy="disk" cachePolicy="disk"
@ -136,6 +164,7 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
autoplay={autoplay} autoplay={autoplay}
transition={{ duration: 200, effect: 'cross-dissolve' }} transition={{ duration: 200, effect: 'cross-dissolve' }}
/> />
</View>
) )
} }

View File

@ -10,7 +10,7 @@ export const IOS_UNIVERSAL_LINK = 'duooomi.bowong.cn'
// 原生版本,原生代码变更时需要更新此版本号 // 原生版本,原生代码变更时需要更新此版本号
export const VERSION = '1.2.0' export const VERSION = '1.2.0'
// JavaScript版本JS代码变更时需要更新此版本号 // JavaScript版本JS代码变更时需要更新此版本号
export const APP_VERSION = 'dev202601211414' export const APP_VERSION = 'dev202601221539'
const ALIPAY_SCHEMA = 'alipay2021006119657394' const ALIPAY_SCHEMA = 'alipay2021006119657394'
const ALIPAY_SCHEMA_SANDBOX = 'alipay9021000158673972' const ALIPAY_SCHEMA_SANDBOX = 'alipay9021000158673972'

View File

@ -61,6 +61,7 @@ export default observer(function TabTwoScreen() {
const [otaUrl, setOtaUrl] = useState( const [otaUrl, setOtaUrl] = useState(
userStore.scannedQR ?? 'https://cdn.roasmax.cn/upload/bf25206ab8644a8fb914aad5cf0fca08.bin', userStore.scannedQR ?? 'https://cdn.roasmax.cn/upload/bf25206ab8644a8fb914aad5cf0fca08.bin',
) )
const [comType, setComType] = useState('0x02') // 默认OTA_PACKAGE类型为128
useEffect(() => { useEffect(() => {
if (userStore.scannedQR) { if (userStore.scannedQR) {
@ -138,10 +139,10 @@ export default observer(function TabTwoScreen() {
renderContent: () => <SyncProgressToast title="OTA升级中" />, renderContent: () => <SyncProgressToast title="OTA升级中" />,
duration: 0, duration: 0,
}) })
const buffer = await bleManager.performOtaUpgrade(otaUrl, (progress) => { const buffer = await bleManager.performOtaUpgrade(otaUrl, comType)
// 进度在 bleStore.transferProgress 中同步显示,这里仅可选处理
}) Toast.hide()
Toast?.show({ title: `OTA升级完成 (${buffer.byteLength} bytes) ` }) Toast?.show({ title: `OTA升级完成 (${buffer.byteLength} bytes) `, duration: 2e3 })
} catch (error) { } catch (error) {
console.error('OTA upgrade failed:', error) console.error('OTA upgrade failed:', error)
const msg = typeof error === 'string' ? error : (error as any)?.message || 'OTA升级失败' const msg = typeof error === 'string' ? error : (error as any)?.message || 'OTA升级失败'
@ -426,6 +427,17 @@ export default observer(function TabTwoScreen() {
/> />
</ThemedView> </ThemedView>
<ThemedView style={{ marginTop: 8 }}>
<ThemedText style={{ marginBottom: 4 }}> Command Type:</ThemedText>
<TextInput
placeholder="蓝牙头"
style={styles.input}
value={comType}
onChangeText={setComType}
placeholderTextColor="#999"
/>
</ThemedView>
<ThemedView style={{ marginTop: 8 }}> <ThemedView style={{ marginTop: 8 }}>
<ThemedText style={{ marginBottom: 4 }}>OTA URL:</ThemedText> <ThemedText style={{ marginBottom: 4 }}>OTA URL:</ThemedText>
<TextInput <TextInput

View File

@ -598,6 +598,7 @@ const HeroCircle = observer<HeroCircleProps>(function HeroCircle({ selectedItem,
const Width = 216 const Width = 216
const previewUrl = selectedItem?.webpHighPreviewUrl || selectedItem?.webpPreviewUrl || '' const previewUrl = selectedItem?.webpHighPreviewUrl || selectedItem?.webpPreviewUrl || ''
const placeholderUrl = selectedItem?.webpPreviewUrl || ''
return ( return (
<Block className="relative z-10 items-center"> <Block className="relative z-10 items-center">
@ -605,7 +606,12 @@ const HeroCircle = observer<HeroCircleProps>(function HeroCircle({ selectedItem,
<Block className="flex-1"> <Block className="flex-1">
<Block className="relative z-10 mt-[20px] flex size-[224px] rounded-full border-4 border-black shadow-deep-black"> <Block className="relative z-10 mt-[20px] flex size-[224px] rounded-full border-4 border-black shadow-deep-black">
<Block className="relative size-full overflow-hidden rounded-full"> <Block className="relative size-full overflow-hidden rounded-full">
<Img style={{ height: Width, width: Width, borderRadius: Width }} width={256} src={previewUrl} /> <Img
style={{ height: Width, width: Width, borderRadius: Width }}
width={256}
placeholderSrc={placeholderUrl}
src={previewUrl}
/>
</Block> </Block>
<Block className="pointer-events-none absolute inset-0 rounded-full border-2 border-black/10" /> <Block className="pointer-events-none absolute inset-0 rounded-full border-2 border-black/10" />

View File

@ -956,7 +956,7 @@ const TopCircleSection = memo(
const GalleryRenderer = memo(({ selectedItem }: { selectedItem: any }) => { const GalleryRenderer = memo(({ selectedItem }: { selectedItem: any }) => {
const url = selectedItem?.url || selectedItem?.imageUrl const url = selectedItem?.url || selectedItem?.imageUrl
const placeholderUrl = selectedItem?.imageUrl
// console.log('GalleryRenderer--------------', selectedItem) // console.log('GalleryRenderer--------------', selectedItem)
if (!url) return null if (!url) return null
@ -967,7 +967,7 @@ const GalleryRenderer = memo(({ selectedItem }: { selectedItem: any }) => {
className="relative z-10 border-4 border-black" className="relative z-10 border-4 border-black"
style={{ width: Width, height: Width, borderRadius: Width, overflow: 'hidden' }} style={{ width: Width, height: Width, borderRadius: Width, overflow: 'hidden' }}
> >
<VideoBox style={{ width: Width, height: Width }} width={Width} url={url} /> <VideoBox style={{ width: Width, height: Width }} width={Width} placeholderUrl={placeholderUrl} url={url} />
</View> </View>
) )
}) })

View File

@ -84,8 +84,8 @@ const ChargePage = observer(function ChargePage() {
const { data, error } = await handleError( const { data, error } = await handleError(
async () => async () =>
await alipay.preRecharge({ await alipay.preRecharge({
credits: 2, // credits: 2,
// credits: parseInt(selectedRecharge?.points.replace(/,/g, '')), credits: parseInt(selectedRecharge?.points.replace(/,/g, '')),
}), }),
) )

View File

@ -649,7 +649,11 @@ class BleManager {
} }
} }
async performOtaUpgrade(url: string, onProgress?: (progress: number) => void): Promise<ArrayBuffer> { async performOtaUpgrade(
url: string,
comType = '0x02',
onProgress?: (progress: number) => void,
): Promise<ArrayBuffer> {
const state = bleStore.state const state = bleStore.state
if (!state.connectedDevice) { if (!state.connectedDevice) {
const error = 'No device connected' const error = 'No device connected'
@ -687,7 +691,8 @@ class BleManager {
await this.fileTransferService.transferFile( await this.fileTransferService.transferFile(
state.connectedDevice.id, state.connectedDevice.id,
arrayBuffer, arrayBuffer,
COMMAND_TYPES.OTA_PACKAGE, // COMMAND_TYPES.OTA_PACKAGE,
Number(comType),
(progress) => { (progress) => {
bleStore.setState((prev) => ({ ...prev, transferProgress: progress * 100 })) bleStore.setState((prev) => ({ ...prev, transferProgress: progress * 100 }))
onProgress?.(progress) onProgress?.(progress)

View File

@ -35,7 +35,7 @@ export const COMMAND_TYPES = {
PREPARE_TRANSFER: 0x14, PREPARE_TRANSFER: 0x14,
} as const } as const
export type APP_COMMAND_TYPES = (typeof COMMAND_TYPES)[keyof typeof COMMAND_TYPES] export type APP_COMMAND_TYPES = (typeof COMMAND_TYPES)[keyof typeof COMMAND_TYPES] | number
export const EVENT_TYPES = { export const EVENT_TYPES = {
TRANSFER_OTA_PACKAGE: { TRANSFER_OTA_PACKAGE: {