尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LibrePhotos 移动端本地图片(Local Images)机制全解析:从相机胶卷同步到时间线合并

LibrePhotos 移动端本地图片(Local Images)机制全解析:从相机胶卷同步到时间线合并 LibrePhotos 移动端本地图片Local Images机制全解析从相机胶卷同步到时间线合并【免费下载链接】librephotosA self-hosted open source photo management service.项目地址: https://gitcode.com/GitHub_Trending/li/librephotos导读LibrePhotos 移动端 Appapps/mobile允许用户在手机相册与自托管服务器之间自由流转照片既可以浏览尚未上传的仅本机照片也可以把服务器上的照片与本地照片合并显示在同一条时间线上。本文将围绕官方贡献者文档 local-images.md 展开深入拆解本地图片的加载、哈希标识、同步状态判定、时间线合并与删除备份等完整链路并结合src/stores下的 zustand store、actions 与后端GET /api/exists/id/接口的源码实现帮助你理解本地图片系统端到端的运作原理为参与移动端开发与贡献提供可直接参考的实现细节。一、四种本地图片状态一切从syncStatus开始文档定义了 App 中一张图片可能处于的四种状态它们共同构成了移动端本地图片模型的基础✅Synced已同步图片已与服务器同步且副本存在于手机上Syncing同步中图片当前正在与服务器同步❌Local仅本地图片未与服务器同步只存在于手机☁️Remote远端图片只存在于服务器不在手机上。需要特别强调的是在移动端代码里同步状态并非用一个布尔值synced表示而是由SyncStatus枚举驱动。该枚举定义在 apps/mobile/src/stores/types/localImages.zod.ts 中实际包含四个成员export enum SyncStatus { SYNCED synced, LOCAL local, SYNCING syncing, FAILED failed, }可以看到代码在文档列出的三态之外还增加了failed上传失败状态。同时每个本地图片对象LocalImage也通过 zod schema 做了运行时校验与默认值兜底syncStatus默认LOCAL、type默认image、rating默认 0、isTemp默认false。这些默认值保证了即使后端返回的字段不完整前端数据模型依然自洽。提示Remote状态并不存在于LocalImage模型里——它是对服务器有、手机没有这一整体情形的描述而不是某个本地图片对象的字段值。二、首次加载loadLocalImages的完整调用链文档指出App 首次加载时会检查新的本地图片。核心入口是 apps/mobile/src/stores/localImagesActions.ts 中的loadLocalImages异步 action它通过react-native-camera-roll读取手机相册并把结果存入 zustand storeuseLocalImagesStoreapps/mobile/src/stores/localImagesStore.ts。2.1 Android 运行时权限检查在 Android 上loadLocalImages会先请求运行时读取权限localImagesActions.ts#L78-L91API level 33请求READ_MEDIA_IMAGESAPI level 33 以下请求READ_EXTERNAL_STORAGE。如果权限未授予action 会立即返回不会设置 loading 标志、不会打任何日志因此时间线保持为空iOS 会跳过这一检查因为 iOS 的相册访问由系统隐私授权统一处理不区分读取类型。从源码看权限检查逻辑是先PermissionsAndroid.check再request只有返回granted才继续往下走async function hasReadAndroidPermission(): Promiseboolean { const permission (Platform.Version as number) 33 ? PermissionsAndroid.PERMISSIONS.READ_MEDIA_IMAGES : PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE const hasPermission await PermissionsAndroid.check(permission) if (hasPermission) return true const status await PermissionsAndroid.request(permission) return status granted }2.2 分页拉取相机胶卷通过权限检查后loadLocalImages以每页 1000 张、assetType: Photos的方式循环调用CameraRoll.getPhotos直到has_next_page为 false 或当前页没有有效照片no_valid_photos为止while (page_info.has_next_page !page_info.no_valid_photos) { page_info await CameraRoll.getPhotos({ first: 1000, after: page_info.end_cursor, assetType: Photos, }).then(async r { const newItems r.edges.filter( item !lastFetch || item.node.timestamp lastFetch, ) const newPhotos await mapPageIgnoringUnreadable(newItems) ... return { ...r.page_info, no_valid_photos: newItems.length 0 } }) } addImages(photos)2.3 逐个资源映射坏文件不拖垮整页每一页的条目会通过camerarollPhotoMapper映射为LocalImage。映射过程对每个资源单独计算 MD5 哈希而这是文档提到的一个关键风险点在 Android 作用域存储scoped storage下react-native-file-access可能无法打开某些 MediaStorecontent://URI。为此代码用Promise.allSettled逐个 settle 每个资源mapPageIgnoringUnreadable单个不可读资源只会被跳过并打印日志绝不会让整页照片消失。这条防御逻辑在 localImagesActions.test.ts 中有专门的回归测试对应 LibrePhotos issue #788No local photos当三张照片中间那张无法哈希时其余两张依然能进入 store。2.4 哈希 ID 与服务端哈希的关系camerarollPhotoMapper的关键计算如下const userId useAuthStore.getState().access?.user_id const hash await FileSystem.hash(item.node.image.uri, MD5) return { id: hash userId, aspectRatio: item.node.image.width / item.node.image.height, ... syncStatus: SyncStatus.LOCAL, ... }即本地图片的id md5(文件) user_id。这个组合 ID 与服务器端照片的image_hash语义对应后端 UploadPhotoExists 视图就是直接拿这个pk去查Photo.objects.get(image_hashpk)命中即认为服务端已存在。组合user_id是为了避免不同用户的同名哈希相互干扰。2.5 持久化与重新水合useLocalImagesStore使用 zustand 的persist中间件存储介质是react-native-async-storage/async-storagestorage key 为localImages-storagelocalImagesStore.ts#L74-L77。因此下次启动 App 时本地图片列表会自动重新水合rehydrate无需重新扫描。store 内部还维护了lastFetch上次拉取时间戳与isLoading标志并暴露了setLoading、addImages、markSynced、markNotSynced、removeImages、reset等 action。其中addImages会在有新图片时把lastFetch更新为当前 Unix 秒数供下次增量扫描使用。三、已知限制fromTime/toTime不可用文档明确标注了该实现的一个限制CameraRoll.getPhotos的fromTime与toTime参数不生效在 react-native-camera-roll 的当前实现/API 组合下因此无法直接按时间区间拉取照片。工程上的绕行方案是保存上次检查的时间戳lastFetch逐页加载后用item.node.timestamp lastFetch做内存过滤。这意味着首次全量拉取后后续每次启动都只处理比上次检查更新的照片代价是增量判断依赖本地时钟与相机胶卷时间戳的一致性且每次仍会扫描到较新的页再过滤。四、合并展示timelineData与isTemp占位符的配合本地图片与服务器图片的合并发生在 apps/mobile/src/Containers/Gallery/Index.js 的timelineDatauseMemo中。它把useLocalImagesStore的本地图片折叠进useFetchDateAlbumsQuery返回的日期相册date albums里且只对With Timestamp按日期分组分类生效。4.1 合并规则对每张本地图片按birthTime格式YYYY-MM-DD找到对应日期分组若该日期分组不存在则新建一个分组并把照片放入其中随后按日期倒序排序若分组已存在则先把同id的服务器条目移除filter(i i.id ! photo.id)再把本地图片插回并按date倒序排列从而实现去重 本地优先若本地图片的syncStatus LOCAL未同步该日期分组的numberOfItems加 1。4.2 占位符的清除合并完成后还有一步收尾mapped中每个日期分组内统计syncStatus SYNCED的本地图片数量syncedCount然后删除同等数量的isTemp true占位符条目Index.js#L207-L218。占位符temp tile是上传流程中预留的空位用于在照片真正上传完成前保持布局稳定当服务器条目被本地已同步图片替换后占位符就失去了意义需要一一清除。关键结论同步状态并非由这个合并逻辑决定。合并只负责展示层的去重与排序syncStatus的判定由下一节的网络检查独立完成。五、同步状态判定GET /api/exists/id/逐个探活checkIfLocalImagesAreSynced()localImagesActions.ts#L140-L160会为 store 里的每一张本地图片请求GET /exists/id/实际完整路径为/api/exists/id/对应后端 librephotos/urls.py 注册的photo_exists路由const result await fetchClient.get{ exists: boolean }(/exists/${image.id}/) if (result.exists) { markSynced(image) } else { markNotSynced(image) }后端UploadPhotoExists.retrieve的实现非常直接upload.py#L72-L81class UploadPhotoExists(viewsets.ViewSet): def retrieve(self, request, pk): try: Photo.objects.get(image_hashpk) return Response({exists: True}) except Photo.DoesNotExist: return Response({exists: False}) except Photo.MultipleObjectsReturned: # Multiple photos with same hash - photo exists return Response({exists: True})即只要按image_hash能查到照片哪怕哈希冲突返回多条就判定已存在于服务器。5.1 store 侧的标记语义markSynced把该图片的syncStatus无条件置为SYNCEDmarkNotSynced带条件——只有当当前状态不是LOCAL时才置为LOCAL避免把已是仅本地的图片反复写同一个状态造成不必要的 re-render请求抛异常如网络失败时同样走markNotSynced分支保证状态收敛。5.2 一键上传syncAllLocalImages()复用上述检查先checkIfLocalImagesAreSynced()再筛选出syncStatus ! SYNCED的图片调用uploadImagesuploadActions实现检查 上传的批量同步动作。六、删除已备份图片removeBackedUpImages业务逻辑集中在removeBackedUpImagesactionlocalImagesActions.ts#L173-L198遍历 store 中所有图片只关心syncStatus SYNCED的条目对每张已同步图片再次请求/exists/id/做二次确认防止本地状态过期导致误删请求失败则跳过该图片对确认存在已备份到服务器的图片调用CameraRoll.deletePhotos从手机删除最后通过removeImages从 store 中移除这些条目保持内存与设备一致。文档特别提到删除操作需要Manage extern storage管理外部存储权限——这与删除/修改相册内容的系统权限要求一致也是react-native-camera-roll删除 API 的前置条件。七、移动端本地图片架构全景把以上链路串起来可以得到完整的端到端数据流阶段触发时机核心代码说明加载App 首次启动loadLocalImages权限检查 → 分页拉取相机胶卷 → MD5 映射 → 入 store持久化每次写入后persistmiddleware存储到 AsyncStoragekey 为localImages-storage状态判定启动/同步前checkIfLocalImagesAreSynced逐个请求/api/exists/id/设置syncStatus合并展示时间线渲染时timelineDatauseMemo仅With Timestamp分类按birthTime归组、按id去重、清理isTemp占位符上传用户触发syncAllLocalImages过滤非SYNCED图片后调用uploadImages删除用户触发removeBackedUpImages二次确认后CameraRoll.deletePhotos 从 store 移除值得注意的工程细节还有id的双重身份md5(file) user_id既是本地 store 的主键也是服务器image_hash的查询键是本地 ↔ 服务端映射的桥梁zod 单源真相LocalImage、SyncStatus等类型全部由 localImages.zod.ts 推导z.inferstore、actions、Gallery 三者共享同一套类型避免手写 interface 造成漂移容错优先分页映射用allSettled隔离坏文件、网络探活用 try/catch 兜底确保单个失败不阻塞整体流程。结语LibrePhotos 移动端的本地图片系统虽然入口只有一个检查新照片的动作但背后串联了 Android 运行时权限、相机胶卷分页、MD5 文件哈希、zustand 持久化、服务端哈希查询、时间线去重合并与相册删除权限等一整套机制。理解了syncStatus三态外加failed如何被loadLocalImages、checkIfLocalImagesAreSynced、timelineData与removeBackedUpImages协作驱动你就能在 apps/mobile/src/stores 中快速定位任何与本地图片相关的问题并安全地扩展新功能。相关测试 localImagesActions.test.ts 展示了如何用 jest mock 相机胶卷与文件哈希来覆盖这类端到端逻辑是移动端贡献者值得研读的样板。【免费下载链接】librephotosA self-hosted open source photo management service.项目地址: https://gitcode.com/GitHub_Trending/li/librephotos创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表