LibrePhotos 移动端本地图片(Local Images)机制全解析:从相机胶卷同步到时间线合并
【免费下载链接】librephotosA self-hosted open source photo management service.项目地址: https://gitcode.com/GitHub_Trending/li/librephotos
导读
LibrePhotos 移动端 App(apps/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 storeuseLocalImagesStore(apps/mobile/src/stores/localImagesStore.ts)。
2.1 Android 运行时权限检查
在 Android 上,loadLocalImages会先请求运行时读取权限(localImagesActions.ts#L78-L91):
- API level 33+:请求
READ_MEDIA_IMAGES; - API level 33 以下:请求
READ_EXTERNAL_STORAGE。
如果权限未授予,action 会立即返回:不会设置 loading 标志、不会打任何日志,因此时间线保持为空(iOS 会跳过这一检查,因为 iOS 的相册访问由系统隐私授权统一处理,不区分读取类型)。
从源码看,权限检查逻辑是先PermissionsAndroid.check再request,只有返回granted才继续往下走:
async function hasReadAndroidPermission(): Promise<boolean> { 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 #788("No 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_hash=pk),命中即认为"服务端已存在"。组合user_id是为了避免不同用户的同名哈希相互干扰。
2.5 持久化与重新水合
useLocalImagesStore使用 zustand 的persist中间件,存储介质是@react-native-async-storage/async-storage,storage key 为localImages-storage(localImagesStore.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-L81):
class UploadPhotoExists(viewsets.ViewSet): def retrieve(self, request, pk): try: Photo.objects.get(image_hash=pk) 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无条件置为SYNCED;markNotSynced:带条件——只有当当前状态不是LOCAL时才置为LOCAL,避免把已是"仅本地"的图片反复写同一个状态造成不必要的 re-render;- 请求抛异常(如网络失败)时,同样走
markNotSynced分支,保证状态收敛。
5.2 一键上传
syncAllLocalImages()复用上述检查:先checkIfLocalImagesAreSynced(),再筛选出syncStatus !== SYNCED的图片调用uploadImages(uploadActions),实现"检查 + 上传"的批量同步动作。
六、删除已备份图片:removeBackedUpImages
业务逻辑集中在removeBackedUpImagesaction(localImagesActions.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 | 存储到 AsyncStorage,key 为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.infer),store、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),仅供参考