时长:50-60 分钟

6.1 功能概述
6.1.1 照片浏览的业务场景
- 测试同学在 PC 端工具上需要查看 iOS 设备相册 中的照片与视频:
- 验证截图、录屏是否保存成功
- 从相册中选取素材用于问题反馈
- 快速浏览设备上的最新照片,无需在手机上逐张点开
- 目标:在工具内完成 相册列表 → 缩略图网格 → 点击预览原图/视频 → 下载到本机 的完整链路,体验接近系统相册。
6.1.2 技术实现难点
- iOS 相册访问限制:
App 只能通过 Photos 框架(PHAsset) 访问相册,无法像 Android 那样直接读文件路径;且 Photos 接口主要在设备本地,不能由 PC 直接调用。
- 跨端通信:
需要在 PC 工具 ↔ iOS 设备 之间建立可靠通道,把“列表、缩略图、原图、视频”等数据从设备传到 PC。
- 性能与体验:
相册可能包含数千张照片,需要 分页、懒加载、缩略图缓存;原图/视频体积大,需要 流式传输、超时与取消,避免卡死或内存溢出。
6.1.3 用户体验设计
- 列表:优先展示“最近照片”资源列表(不分相册),支持滚动加载更多。
- 缩略图:网格展示,懒加载 + 占位图;缩略图由设备端生成并缓存,控制单张体积(如 ≤30KB)。
- 预览:点击后弹出预览对话框,原图/视频按需加载;支持上一张/下一张、键盘快捷键、下载、用系统查看器打开。
6.2 技术架构
6.2.1 photo-proxy 服务:自定义二进制协议
- 定位:运行在 PC 端(Electron 主进程) 的桥接服务,负责与 iOS 设备上的 PhotoCompanion 通信。
- 协议形式:基于 TCP 的二进制帧协议,而非 HTTP,以便:
- 在一个连接上复用多种请求(列表、缩略图、原图、视频)
- 对大数据量做流式收发,避免一次性组包
- 帧格式(与爱思助手级别实现对齐):
- Magic:
0x50484F54("PHOT") - Version:1
- Type:帧类型(见下)
- RequestId:请求 ID,用于匹配请求与响应
- PayloadLen:payload 长度
- Payload:JSON(控制类)或二进制(DATA 帧)
- Magic:
6.2.2 PhotoCompanion 服务:iOS 端 Swift 实现
- 定位:安装在 iOS 设备上的 Companion App,在设备本地调用 Photos 框架 获取相册数据,并通过 TCP 与 PC 通信。
- 监听:使用 Network.framework 的
NWListener在固定端口(如 12345)监听,等待 PC 通过 usbmuxd 端口转发 连入。 - 能力:
- 解析二进制帧,根据帧类型分发到
handleListAssets、handleGetThumb、handleGetImage、handleGetVideo - 使用
PHAsset、PHImageManager获取资源列表、缩略图、原图、视频 - 缩略图使用 NSCache 内存缓存;原图/视频可流式发送(分块 DATA 帧)。
- 解析二进制帧,根据帧类型分发到
6.2.3 usbmuxd + lockdown:设备通信基础
- usbmuxd:macOS 上的系统服务,管理 USB 连接的 iOS 设备,提供多路复用与端口转发。
- go-ios:本项目使用的命令行工具,通过 usbmuxd 与设备通信,可执行
ios forward将设备上的端口映射到本机。 - 连接建立:
PC 执行 ios forward 12345,将设备的 12345 端口映射到本机;PC 上的 photo-proxy 再连接本机该端口,即等价于与设备上 PhotoCompanion 的 12345 服务建立 TCP 连接。
- lockdown:设备上的锁屏/配对服务;部分能力(如开发者镜像、服务注册)会依赖 lockdown,但照片通道主要依赖 端口转发 + PhotoCompanion 监听。
6.2.4 端口转发:本地服务桥接
- 流程:
Electron 主进程 → PhotosService(Node)→ BridgeServicePortForward → go-ios forward 12345 → 设备 12345 → PhotoCompanionServiceBinary(Swift)。
- BridgeServicePortForward:
负责启动/复用 go-ios 转发进程、维护 TCP Socket、按帧序列化/反序列化、匹配 requestId、处理流式响应(多个 DATA 帧)与超时。
6.3 照片获取流程
6.3.1 相册列表获取
LIST_ASSETS 帧类型
- 请求:PC 发送
FrameType.LIST_ASSETS,payload 为 JSON:{ "limit": 200, "offset": 0 }。 - 响应:设备返回同类型帧,payload 为 JSON:
{ "total": N, "items": [ { "assetId", "type", "width", "height", "creationTime", ... } ] }。 - assetId:使用
PHAsset.localIdentifier,作为后续缩略图、原图、视频请求的唯一标识。
分页加载机制
- 首次请求通常
limit=200, offset=0;前端可根据滚动位置再请求offset=200等实现分页。 - 设备端在
handleListAssets内根据offset和limit对PHAsset.fetchAssets结果做切片,只返回当前页的 items,避免单次响应过大。
相册分类处理
- 当前实现为 扁平资源列表(按创建时间倒序),不按系统相册分类;若需“按相册”展示,可在设备端按
PHAssetCollection分组,或在前端对items做二次分组。
性能优化(懒加载)
- 列表只渲染当前视口内的项;缩略图在项进入视口时才通过
photoProxy.getThumbnail(assetId, size)请求,避免首屏请求过多。
6.3.2 缩略图加载
GET_THUMB 帧类型
- 请求:
FrameType.GET_THUMB,payload:{ "assetId": "...", "size": 320 }。 - 响应:设备返回 单个 DATA 帧,内容为 JPEG 二进制(缩略图),PC 端解析后转为 base64 或 Blob URL 供前端显示。
缩略图缓存机制
- 设备端:
ServiceBinary内使用NSCache,key 为thumb:(assetId):(size),命中则直接回传,不重复调用PHImageManager。 - PC 端:可选的磁盘/内存缓存(如 thumbnail-cache 模块);前端也可对已加载的
thumbUrl做保留,避免重复请求。
懒加载实现
- 网格项进入视口时(如通过 IntersectionObserver 或滚动事件)调用
loadPhotoThumbnail(item),请求getThumbnail并写入item.thumbUrl。 - 可设置
_thumbLoading防止同一项重复请求。
加载失败处理
- 请求失败或超时:保留占位图或错误图标,不阻塞列表;可重试或忽略。
- 若 Photos Service 不可用,前端可回退到 fsync tree 等其它数据源(若项目有实现)。
6.3.3 原图/视频获取
GET_IMAGE 帧类型
- 请求:
FrameType.GET_IMAGE,payload:{ "assetId": "...", "quality": "full" | "screen" }。 - 响应:设备通过 流式 DATA 帧 发送图片二进制(多帧),最后一帧可为空 payload 表示结束;PC 端用 Readable Stream 收集后合并为 Buffer,再转 base64 或 Blob。
GET_VIDEO 帧类型
- 请求:
FrameType.GET_VIDEO,payload:{ "assetId": "..." }。 - 响应:同样为流式 DATA 帧;设备端使用
PHImageManager.requestAVAsset(forVideo:...)获取视频,再按块读取并发送。
流式传输
- 大文件不分块一次性发会导致延迟和内存峰值;流式发送/接收可边收边展示(如视频边下边播)或边收边写文件。
- PC 端
sendRequest(type, payload, expectStream: true)会返回一个 Stream,handleFrame收到 DATA 帧时向该 Stream push 数据,收到空 DATA 时 end。
大文件处理
- 超时:GET_IMAGE / GET_VIDEO 使用较长超时(如 600 秒),避免大视频未传完就断开。
- HEIC:设备端在
handleGetImage中检测 HEIC,转为 JPEG 再发送,避免浏览器无法显示。 - 内存:前端对大 base64 可做分块解码或使用 Blob URL,避免一次性分配过大字符串。
6.4 预览功能实现

6.4.1 预览对话框
- 图片预览组件:使用
img或带缩放/拖拽的图片组件,src为 data URL 或 Blob URL(由loadPreviewMedia设置item.previewUrl)。 - 视频播放组件:使用
video,src为 Blob URL;可暴露播放控制(播放/暂停、进度条)。 - 加载状态处理:预览打开时
previewLoading = true,请求getImage/getVideoStream完成并生成 URL 后设为 false,并设置previewUrl。 - 错误处理:请求失败或超时则提示“加载失败”,并可保留上一张/下一张导航,不关闭对话框。
6.4.2 导航功能
- 上一张/下一张:维护
previewIndex与当前列表iosAlbumItems,切换时更新previewItem并重新调用loadPreviewMedia(newItem)。 - 键盘快捷键:监听左/右箭头切换上一张/下一张,Escape 关闭预览。
- 图片计数器:显示“当前第 n 张 / 共 m 张”。
- 平滑切换动画:可选 CSS 过渡或淡入淡出,提升观感。
6.4.3 下载功能
- 原图下载:通过
photoProxy.getImage(assetId, 'full')获取 base64,再通过主进程saveFileFromBase64写入 userData/ios-photos-cache(或可配置目录),避免写入 app.asar。 - 视频下载:通过
photoProxy.getVideoStream(assetId)获取 base64,同样写入本地文件。 - 本地文件保存:保存路径使用
getPhotoCacheDir()等 IPC 获取可写目录,文件名可带时间戳与扩展名(.jpg / .mp4)。 - 系统查看器打开:保存成功后,通过
executeCommand调用系统命令(如 macOSopen "path")打开文件。
6.5 关键技术点
6.5.1 自定义二进制协议
- 帧格式设计:见 6.2.1;Header 固定 16 字节,Payload 长度由 Header 指定,便于按帧切分。
- 帧类型定义:
LIST_ASSETS=1, GET_THUMB=2, GET_IMAGE=3, GET_VIDEO=4, OPEN_URL=5, AUTO_LOGIN=6, HTTP_REQUEST=7, DATA=100, ERROR=500。
- 数据编码/解码:
控制类请求/响应用 JSON 编码 payload;二进制数据用 DATA 帧;错误信息用 ERROR 帧 + UTF-8 字符串。
- 错误处理:
设备端发生错误时发送 ERROR 帧,PC 端在 handleFrame 中 reject 对应 requestId 的 Promise,前端即可收到错误并提示。
6.5.2 端口转发机制
- usbmuxd 端口转发:通过 go-ios 的
ios forward 12345将设备 12345 映射到本机,photo-proxy 连接本机该端口即连到设备。 - 本地服务桥接:PhotosService(Node)内部使用 BridgeServicePortForward,负责建连、发帧、收帧、超时与重连。
- 连接管理:
同一设备可复用连接;设备变更或断连时 disconnect 后重新 connect;通过 isConnectionHealthy() 检查 socket 是否可写,必要时重连。
- 超时处理:
列表/缩略图类请求 60 秒;GET_IMAGE/GET_VIDEO 流式请求 600 秒;超时后清理 pendingRequests 并 reject。
6.5.3 缓存策略
- 缩略图缓存:
- 设备端:NSCache,countLimit/totalCostLimit 控制内存;
- PC 端:可选磁盘缓存(如按 assetId+size 存文件)。
- 内存缓存:
设备端 thumbCache;前端可对已加载的 thumbUrl/previewUrl 做保留,避免重复请求。
- 磁盘缓存:
若实现,路径应在 userData 或可写目录,避免打包后写 app.asar。
- 缓存清理:
可由用户触发“清空缓存”,或按 LRU/TTL 淘汰。
6.5.4 性能优化
- 懒加载:列表与缩略图均按需请求;预览仅在打开时请求原图/视频。
- 预加载:设备端在 LIST_ASSETS 返回后,对下一批 PHAsset 调用
startCachingImages,提升后续 GET_THUMB 速度。 - 请求取消(AbortController):
预览切换时,前端使用 AbortController 取消上一次的 loadPreviewMedia,避免旧请求完成后覆盖新预览。
- 分页加载:列表分页(limit/offset),避免单次拉取过多。
6.6 完整实现流程
用户点击相册
→ 检查设备连接
→ 设置设备并建立 photo-proxy 连接(go-ios forward + TCP)
→ 发送 LIST_ASSETS(limit=200, offset=0)
→ 接收相册列表(total + items)
→ 前端展示资源网格,thumbUrl 为空
→ 滚动时懒加载缩略图:GET_THUMB(assetId, size) → 显示缩略图
→ 用户点击某张图片/视频
→ 打开预览对话框,previewLoading = true
→ 根据 type 发送 GET_IMAGE 或 GET_VIDEO
→ 接收流式 DATA 帧,合并为 Buffer → base64 → Blob URL
→ 设置 previewUrl,previewLoading = false,显示预览
→ 用户可切换上一张/下一张、下载、用系统查看器打开
6.7 代码实现详解
6.7.1 loadIOSAlbums() 函数解析
- 职责:加载当前设备的“照片资源列表”并写入
iosAlbumItems,供网格展示。 - 流程:
- 若有
photoProxy,则setDevice→connect→listAssets(200, 0); - 若返回成功且有
items,映射为带assetId、thumbUrl: null的项,并可选挂载滚动懒加载; - 若 photoProxy 不可用或失败,可回退到 fsync tree 等(若实现)。
- 注意:确保每个 item 有
assetId(来自localIdentifier),缺则尝试用id或其它标识,否则后续缩略图/预览无法请求。
6.7.2 getPhotoPreviewUrl() 与预览数据流
- 说明:iOS 端预览走 photoProxy.getImage / getVideoStream,不再走“文件路径 + getPhotoPreviewUrl”;
getPhotoPreviewUrl在项目中多用于 Android/fsync 拉取文件到本地再读 的路径。 - iOS 预览数据流:
loadPreviewMedia(item) → 判断 item.type → photoProxy.getImage(assetId, 'full') 或 photoProxy.getVideoStream(assetId) → 主进程收集流 → 返回 base64 → 前端转 Blob URL 赋给 item.previewUrl。
6.7.3 loadPreviewMedia() 函数解析
- 职责:为当前预览项加载原图或视频,并设置
previewUrl、关闭 loading。 - 流程:
- 若缺少
assetId,先尝试listAssets刷新列表并匹配当前项,补全assetId; - 使用
AbortController取消上一次加载; - 若为 video,调用
getVideoStream,收集 base64 后转 Blob,生成 Blob URL; - 若为 image,调用
getImage,同样转 data URL 或 Blob URL; - 检测 HEIC 时前端可提示或跳过显示;
- 设置
previewUrl、previewLoading = false,异常时提示并保持 loading 关闭。
6.7.4 ServiceBinary.swift 中的照片处理逻辑
- handleListAssets:
使用 PHAsset.fetchAssets 按创建时间倒序,按 offset/limit 切片,构造 { total, items },items 含 assetId、type、width、height、creationTime 等;可选预取下一批缩略图。
- handleGetThumb:
先查 NSCache;未命中则 PHImageManager.requestImage 生成缩略图,JPEG 压缩后控制在约 30KB 内,写入缓存并发送单 DATA 帧。
- handleGetImage:
requestImageDataAndOrientation 取原图;若为 HEIC 则转 JPEG 再通过 sendStream 分 DATA 帧发送。
- handleGetVideo:
requestAVAsset(forVideo:...) 取视频,读取文件或导出为可读数据后,通过 sendStream 分块发送。
6.8 调试与排障
6.8.1 连接问题排查
现象:相册一直加载不出或提示“连接失败”。
排查:
- 设备是否已用 USB 连接、是否信任本机;
- go-ios 是否可用(
ios list能否看到设备); ios forward 12345是否成功(本机lsof -i :12345是否有进程);- PhotoCompanion 是否已安装并在设备上运行、是否在监听 12345(看 Xcode 控制台 “Listener ready on port: 12345”)。
现象:之前能用,突然连不上。
排查:
- 是否换了 USB 口或线、是否锁屏/休眠;
- 主进程日志中是否有 “Connection lost”“Socket destroyed” 等,必要时重启 PhotoCompanion 或工具。
6.8.2 数据传输问题
现象:列表有,缩略图不显示。
排查:
- 控制台是否报 getThumbnail 超时或 ERROR 帧;
- 设备端是否打印 “Cache hit” 或 “Failed to generate thumbnail”;
- assetId 是否与 LIST_ASSETS 返回一致(localIdentifier)。
- 现象:预览一直转圈或报错。
排查:
- GET_IMAGE/GET_VIDEO 是否超时(如 600s 仍不足);
- 是否收到 ERROR 帧(设备端日志);
- HEIC 是否在设备端已转 JPEG,或前端是否对 HEIC 做了兼容/提示。
6.8.3 性能优化技巧
- 缩略图 size 不宜过大(如 320 已够网格);设备端已做 30KB 限制可再压低质量。
- 列表分页,避免单次 LIST_ASSETS 的 limit 过大。
- 预览切换时务必取消上一次请求(AbortController 或等效),避免堆积。
- 大视频可考虑“只下载不预览”或降质预览,减少长时间占流。
6.8.4 常见错误处理
| 现象 | 可能原因 | 建议 |
| photoProxy 不可用 | 主进程未注入或 PhotoCompanion 未连接 | 检查 Electron 主进程日志、设备连接与 forward |
| LIST_ASSETS 返回空 | 设备相册无图或权限未授权 | 在设备上打开 PhotoCompanion 并允许相册权限 |
| 缩略图/原图超时 | 网络慢、iCloud 未下载、设备卡顿 | 增大超时或提示用户“正在从 iCloud 下载” |
| 预览黑屏/无法播放 | 格式不支持(如 HEIC)、Blob 未正确生成 | 设备端 HEIC→JPEG;检查 base64→Blob 与 video src |
| 下载失败 ENOTDIR | 保存路径在 app.asar 下 | 使用 getPhotoCacheDir() 等可写目录 |
> 本期从业务场景出发,梳理了 iOS 端图片预览的完整链路: > 从 photo-proxy 二进制协议、PhotoCompanion 与 Photos 框架、端口转发与连接管理,到前端的 loadIOSAlbums / loadPreviewMedia / 下载 实现与调试要点,便于大家理解“PC 如何安全、高效地浏览和下载设备相册”。







Comments | NOTHING