说明:这个工具是和 Claude Code 协作开发的,本文也由 Claude 协助整理成文。文中代码是从项目里摘的关键片段。
前言:为什么要做这个
初代 Pixel 和 Pixel XL 有一个很出名的权益:Google 相册"原始画质"备份免费、不限量,而且终身有效。后来的 Pixel 机型都没有这么完整的权益了,所以不少人把旧 Pixel 留着当"备份机"。
先说清楚一个灰色地带。Google 当年的说法是,这项权益针对的是用 Pixel 拍摄的照片和视频;从其他设备上传的内容,按原始画质备份是要占用空间配额的。但实际使用中,从 Pixel 上传的、别的设备拍的照片,目前看也不占空间。这属于官方条款和实际表现不一致的地方,政策随时可能收紧,这么用的风险落在自己的 Google 账号上,请自行判断。
我手上有一台 Pixel XL(Android 10),想把 Mac 上的照片和视频(包括"照片"图库里的)原样传到它的相册里,让 Google 相册去备份。对这个工具的要求是:
- 逐字节原样:不压缩、不转码、不改 EXIF;
- 不重复、不丢:传过的不再传,传坏了能发现;
- 断了能接着传:上千个文件,中途断线不能从头来;
- 看得到备份进度:手机上 Google 相册备份到哪了,在 Mac 上就能看到。
整个工具分两端:Mac 端是 SwiftUI 写的 App,手机端是 Kotlin + Jetpack Compose,内嵌一个 HTTP 服务。这篇讲传输链路;下篇讲怎么不 root 读手机的状态(Google 相册备份进度、温度、电量),以及格式支持的实测方法。
一、整体结构
#mermaid-svg-EzTHtypAjidZtefK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EzTHtypAjidZtefK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EzTHtypAjidZtefK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EzTHtypAjidZtefK .error-icon{fill:#552222;}#mermaid-svg-EzTHtypAjidZtefK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EzTHtypAjidZtefK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EzTHtypAjidZtefK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EzTHtypAjidZtefK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EzTHtypAjidZtefK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EzTHtypAjidZtefK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EzTHtypAjidZtefK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EzTHtypAjidZtefK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EzTHtypAjidZtefK .marker.cross{stroke:#333333;}#mermaid-svg-EzTHtypAjidZtefK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EzTHtypAjidZtefK p{margin:0;}#mermaid-svg-EzTHtypAjidZtefK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EzTHtypAjidZtefK .cluster-label text{fill:#333;}#mermaid-svg-EzTHtypAjidZtefK .cluster-label span{color:#333;}#mermaid-svg-EzTHtypAjidZtefK .cluster-label span p{background-color:transparent;}#mermaid-svg-EzTHtypAjidZtefK .label text,#mermaid-svg-EzTHtypAjidZtefK span{fill:#333;color:#333;}#mermaid-svg-EzTHtypAjidZtefK .node rect,#mermaid-svg-EzTHtypAjidZtefK .node circle,#mermaid-svg-EzTHtypAjidZtefK .node ellipse,#mermaid-svg-EzTHtypAjidZtefK .node polygon,#mermaid-svg-EzTHtypAjidZtefK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EzTHtypAjidZtefK .rough-node .label text,#mermaid-svg-EzTHtypAjidZtefK .node .label text,#mermaid-svg-EzTHtypAjidZtefK .image-shape .label,#mermaid-svg-EzTHtypAjidZtefK .icon-shape .label{text-anchor:middle;}#mermaid-svg-EzTHtypAjidZtefK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EzTHtypAjidZtefK .rough-node .label,#mermaid-svg-EzTHtypAjidZtefK .node .label,#mermaid-svg-EzTHtypAjidZtefK .image-shape .label,#mermaid-svg-EzTHtypAjidZtefK .icon-shape .label{text-align:center;}#mermaid-svg-EzTHtypAjidZtefK .node.clickable{cursor:pointer;}#mermaid-svg-EzTHtypAjidZtefK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EzTHtypAjidZtefK .arrowheadPath{fill:#333333;}#mermaid-svg-EzTHtypAjidZtefK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EzTHtypAjidZtefK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EzTHtypAjidZtefK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EzTHtypAjidZtefK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EzTHtypAjidZtefK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EzTHtypAjidZtefK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EzTHtypAjidZtefK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EzTHtypAjidZtefK .cluster text{fill:#333;}#mermaid-svg-EzTHtypAjidZtefK .cluster span{color:#333;}#mermaid-svg-EzTHtypAjidZtefK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EzTHtypAjidZtefK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EzTHtypAjidZtefK rect.text{fill:none;stroke-width:0;}#mermaid-svg-EzTHtypAjidZtefK .icon-shape,#mermaid-svg-EzTHtypAjidZtefK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EzTHtypAjidZtefK .icon-shape p,#mermaid-svg-EzTHtypAjidZtefK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EzTHtypAjidZtefK .icon-shape .label rect,#mermaid-svg-EzTHtypAjidZtefK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EzTHtypAjidZtefK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EzTHtypAjidZtefK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EzTHtypAjidZtefK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 手机 Pixel XL
Mac
数据线:adb forward
127.0.0.1:18080 → 手机 8080
Wi-Fi:Bonjour 发现
_pixelphoto._tcp
待传队列
queue.json
Uploader
URLSession
NanoHTTPD :8080
DCIM/PixelPhoto
媒体库扫描
Google 相册自动备份
思路很朴素:手机上跑一个 HTTP 服务,Mac 把文件 POST 过去;手机存进相册目录,通知媒体库扫描,剩下的交给 Google 相册的自动备份。难点都在细节里。
二、手机端:只有三个接口的 HTTP 服务
手机端用的是 NanoHTTPD,一个单文件的嵌入式 HTTP 服务器,很适合在 App 里起一个小服务。一共三个接口:
| 接口 | 作用 |
|---|---|
GET / |
设备信息:型号、Android 版本、剩余空间。Mac 端用它确认"连上了" |
GET /exists?name=&size=&sha256= |
按内容判断手机上是否已经有这个文件 |
POST /upload?name=&mtime= |
请求体就是文件的原始字节,返回手机收到内容的 sha256 |
2.1 流式接收:边收边写边算 sha256
视频动辄几个 GB,不能先读进内存。接收时按 1 MB 一块,读一块、写一块、同时更新一次摘要:
kotlin
/** 边收边写,同时算 sha256,返回十六进制摘要 */
private fun copyBody(session: IHTTPSession, out: java.io.File, length: Long, onProgress: (Long) -> Unit): String {
val input = session.inputStream
val digest = MessageDigest.getInstance("SHA-256")
val buf = ByteArray(1 shl 20)
var done = 0L
var lastReport = 0L
out.outputStream().buffered(1 shl 20).use { os ->
while (done < length) {
val n = input.read(buf, 0, minOf(buf.size.toLong(), length - done).toInt())
if (n < 0) throw IOException("连接中断(收到 $done / $length 字节)")
os.write(buf, 0, n)
digest.update(buf, 0, n)
done += n
if (done - lastReport >= 4 shl 20) { // 每 4 MB 报一次进度
onProgress(done)
lastReport = done
}
}
}
return digest.digest().toHex()
}
几个要点:
- 必须有 Content-Length 。HTTP 连接是复用的,服务端只能靠长度知道这个文件到哪结束,读够
length字节就停,不能读到流结束。 - 读到 -1 就是连接断了,抛异常,交给外层清理。
- 摘要是对"实际写进去的字节"算的。Mac 端拿这个值和本地算的比,一致才算传成功,相当于端到端校验了"网络 + 磁盘写入"整条链路。
2.2 先写 .part,写完再改名
kotlin
fun partFileFor(target: File) = File(target.parentFile, ".${target.name}.part")
fun publish(context: Context, part: File, target: File, lastModified: Long?) {
check(part.renameTo(target)) { "无法重命名为 ${target.name}" }
if (lastModified != null && lastModified > 0) target.setLastModified(lastModified)
MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), null, null)
}
文件先写成 .IMG_0001.jpg.part,全部收完、校验过再改名成 IMG_0001.jpg。原因有两个:
- 相册永远看不到半截文件。以点开头的是隐藏文件,媒体库不会去扫;改名在同一个目录里完成,是原子操作,要么还是 .part,要么已经是完整的文件。Google 相册只会备份完整的文件。
- 断了好清理 。App 被杀、手机重启,留下的只会是 .part 文件。服务启动时把它们删掉就行,只删本 App 自己起的
.xxx.part,不碰别的。
改名之后做两件事:把文件修改时间设成 Mac 上的原始时间,再通知媒体库扫描。这样系统相册和 Google 相册里的时间顺序都和原来一致。文件内容本身一个字节都不改,没有拍摄时间的也不替它补 EXIF。
2.3 拒绝请求时,要把连接断掉
kotlin
/** 拒绝时请求体还没读,必须断开连接,否则剩下的字节会被当成下一个请求 */
private fun reject(status: Response.Status, message: String) =
json(status, false, message).apply { closeConnection(true) }
这是个容易忽略的 HTTP 细节。比如手机空间不够,服务端在读请求体之前就要返回错误。可客户端已经在发文件了,这些字节还堆在连接里。如果连接保持复用(keep-alive),服务端接着读,就会把一堆 JPEG 字节当成"下一个 HTTP 请求"去解析。所以只要是没读完请求体就返回的情况,都必须关闭连接。
2.4 同名不同内容:另存为 xxx (1).jpg
不同相机拍的照片经常重名(IMG_0001.jpg 满世界都是)。手机端发现同名文件已经存在时,不覆盖,而是另存为 IMG_0001 (1).jpg。
对应地,/exists 判断"传没传过"时也不能只看文件名,要在同名文件和它的改名版本里,找大小相同、sha256 也相同的那个:
kotlin
fun existingWithHash(name: String, size: Long, sha256: String): File? {
val clean = cleanName(name)
val base = clean.substringBeforeLast('.')
val ext = clean.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }
val renamed = Regex("^" + Regex.escape(base) + " \\(\\d+\\)" + Regex.escape(ext) + "$")
return dir.listFiles { f -> f.isFile && f.length() == size && (f.name == clean || renamed.matches(f.name)) }
?.sortedBy { it.name.length }
?.firstOrNull { sha256(it).equals(sha256, ignoreCase = true) }
}
先按大小过滤,大小不同的直接排除,只有大小一致的才去读文件算 sha256,避免每次都把一堆大文件读一遍。
2.5 Android 10 的存储:直接写 DCIM
Android 10 引入了分区存储,正常情况下应用不能再随意用文件路径往 DCIM 里写。这台手机是 Android 10,清单里声明 android:requestLegacyExternalStorage="true" 后,还能沿用旧的存储模式,直接用 File 写 DCIM/PixelPhoto/,配合 setLastModified 保留原始时间,实现起来最简单。
如果是 Android 11 以上的手机,这个开关就不起作用了,得改用 MediaStore 插入。这个工具只为这一台 Android 10 的 Pixel XL 服务,所以没做这部分适配。
2.6 锁屏了也不断:前台服务 + 两把锁
传上千个文件要好几个小时,手机不可能一直亮着屏。接收服务是一个前台服务(foregroundServiceType="dataSync"),启动时再拿两把锁:
kotlin
wifiLock = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "PixelPhoto:wifi").apply { acquire() }
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PixelPhoto:cpu").apply { acquire() }
- 前台服务:通知栏常驻一条"正在等待接收",系统不会因为内存紧张或省电把它杀掉。
- Wi-Fi 锁(高性能模式):锁屏后 Wi-Fi 默认会进入省电模式,吞吐量明显下降,这把锁让它保持全速。
- CPU 唤醒锁(PARTIAL):屏幕可以灭,但 CPU 不休眠,写盘和算摘要不会停。
另外还有一个开机自启的广播接收器:手机重启后,第一次解锁时自动把服务拉起来。
三、两条连接路径:数据线和 Wi-Fi
3.1 数据线:adb forward
插着数据线时,Mac 端执行:
bash
adb forward tcp:18080 tcp:8080
它的原理是:Mac 上的 adb 后台服务开始监听本机 127.0.0.1:18080;每当有程序连上这个端口,adb 就通过 USB 在手机上建立一条到 8080 端口的连接,把两边的数据原样对接起来。所以 Mac 端的 HTTP 客户端只管访问 http://127.0.0.1:18080,完全不知道背后走的是 USB。
实测数据线大约 24.6 MB/s 。另外插着线时还能顺便用 adb 做别的事,比如手机端的接收服务没开,可以远程用 am start 把它打开,以及下篇要讲的读取手机状态。
一个小细节:Mac 端每 3 秒刷新一次连接状态,每次都会确保转发存在。但如果每次都重新执行一遍 adb forward,正在传输的连接可能会被打断,所以先查一下已有的转发,已经有了就跳过:
swift
/// 已经有同样的转发就不再重复设置,免得打断正在进行的传输
func ensureForward(serial: String, local: Int, remote: Int) async {
let list = await run(["forward", "--list"]) ?? ""
if list.contains("\(serial) tcp:\(local) tcp:\(remote)") { return }
_ = await run(["-s", serial, "forward", "tcp:\(local)", "tcp:\(remote)"])
}
3.2 Wi-Fi:Bonjour 广播
不插线时,靠 Bonjour(mDNS/DNS-SD)自动发现。手机端在局域网里广播一个 _pixelphoto._tcp 服务,把自己的 IP 直接写进 TXT 记录:
kotlin
val info = NsdServiceInfo().apply {
serviceName = Build.MODEL
serviceType = "_pixelphoto._tcp"
port = 8080
setAttribute("ip", ip)
setAttribute("model", Build.MODEL)
}
nsdManager.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
Mac 端用 Network 框架的 NWBrowser 浏览这个服务类型,结果里直接带着 TXT 记录,取出 ip 就能拼出地址:
swift
let browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_pixelphoto._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { results, _ in
for result in results {
guard case let .service(name, _, _, _) = result.endpoint,
case let .bonjour(txt) = result.metadata,
let ip = txt["ip"] else { continue }
// http://<ip>:8080 就是这台手机
}
}
为什么要把 IP 放进 TXT?标准的 DNS-SD 流程是"浏览到服务实例 → 解析 SRV 记录拿到主机名和端口 → 再解析主机名拿到 IP",多了两步解析。直接把 IP 放进 TXT,浏览到就能用。
代价是 IP 变了要重新广播。手机端注册了网络回调,Wi-Fi 断开、重连、地址变化时都会触发;回调里等 500 毫秒再查 IP(刚连上时 IP 往往还没分配好),IP 确实变了,就撤掉旧广播、用新 IP 重新广播。
3.3 两条路同时找,数据线优先
Mac 端每 3 秒刷新一次:先看 adb 有没有设备,有就设好转发、访问 GET / 确认服务在线;再把 Bonjour 找到的手机逐个访问一遍。两条路都通时优先用数据线,因为速度差了十倍:Mac 连 2.4G Wi-Fi 时实测只有约 2.4 MB/s。
四、Mac 端:一个文件的完整旅程
每个文件按这个顺序处理:
#mermaid-svg-fE7JUl7fwz3pZiwN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fE7JUl7fwz3pZiwN .error-icon{fill:#552222;}#mermaid-svg-fE7JUl7fwz3pZiwN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fE7JUl7fwz3pZiwN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fE7JUl7fwz3pZiwN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fE7JUl7fwz3pZiwN .marker.cross{stroke:#333333;}#mermaid-svg-fE7JUl7fwz3pZiwN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fE7JUl7fwz3pZiwN p{margin:0;}#mermaid-svg-fE7JUl7fwz3pZiwN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fE7JUl7fwz3pZiwN .cluster-label text{fill:#333;}#mermaid-svg-fE7JUl7fwz3pZiwN .cluster-label span{color:#333;}#mermaid-svg-fE7JUl7fwz3pZiwN .cluster-label span p{background-color:transparent;}#mermaid-svg-fE7JUl7fwz3pZiwN .label text,#mermaid-svg-fE7JUl7fwz3pZiwN span{fill:#333;color:#333;}#mermaid-svg-fE7JUl7fwz3pZiwN .node rect,#mermaid-svg-fE7JUl7fwz3pZiwN .node circle,#mermaid-svg-fE7JUl7fwz3pZiwN .node ellipse,#mermaid-svg-fE7JUl7fwz3pZiwN .node polygon,#mermaid-svg-fE7JUl7fwz3pZiwN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fE7JUl7fwz3pZiwN .rough-node .label text,#mermaid-svg-fE7JUl7fwz3pZiwN .node .label text,#mermaid-svg-fE7JUl7fwz3pZiwN .image-shape .label,#mermaid-svg-fE7JUl7fwz3pZiwN .icon-shape .label{text-anchor:middle;}#mermaid-svg-fE7JUl7fwz3pZiwN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fE7JUl7fwz3pZiwN .rough-node .label,#mermaid-svg-fE7JUl7fwz3pZiwN .node .label,#mermaid-svg-fE7JUl7fwz3pZiwN .image-shape .label,#mermaid-svg-fE7JUl7fwz3pZiwN .icon-shape .label{text-align:center;}#mermaid-svg-fE7JUl7fwz3pZiwN .node.clickable{cursor:pointer;}#mermaid-svg-fE7JUl7fwz3pZiwN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fE7JUl7fwz3pZiwN .arrowheadPath{fill:#333333;}#mermaid-svg-fE7JUl7fwz3pZiwN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fE7JUl7fwz3pZiwN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fE7JUl7fwz3pZiwN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fE7JUl7fwz3pZiwN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fE7JUl7fwz3pZiwN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fE7JUl7fwz3pZiwN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fE7JUl7fwz3pZiwN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fE7JUl7fwz3pZiwN .cluster text{fill:#333;}#mermaid-svg-fE7JUl7fwz3pZiwN .cluster span{color:#333;}#mermaid-svg-fE7JUl7fwz3pZiwN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fE7JUl7fwz3pZiwN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fE7JUl7fwz3pZiwN rect.text{fill:none;stroke-width:0;}#mermaid-svg-fE7JUl7fwz3pZiwN .icon-shape,#mermaid-svg-fE7JUl7fwz3pZiwN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fE7JUl7fwz3pZiwN .icon-shape p,#mermaid-svg-fE7JUl7fwz3pZiwN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fE7JUl7fwz3pZiwN .icon-shape .label rect,#mermaid-svg-fE7JUl7fwz3pZiwN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fE7JUl7fwz3pZiwN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fE7JUl7fwz3pZiwN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fE7JUl7fwz3pZiwN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
有
没有
不够
够
一致
不一致
从待传队列取一个文件
来自「照片」图库?
导出原件到临时目录
本地算 sha256
手机上已有?
GET /exists
标记「手机上已有」
视为已校验
手机剩余空间够?
预留 500MB
标记「空间不足」,跳过
POST /upload 流式上传
手机返回的 sha256
和本地一致?
完成,已校验
失败,保留原件
4.1 为什么先在本地算 sha256
本地 SSD 读文件、算 sha256 的速度是 GB/s 级,比传输快两个数量级,先算好几乎不花时间,却带来两个好处:
- 去重:手机上已经有内容完全一样的文件,就直接跳过,不浪费传输时间。重复添加同一批照片、或者中途断了重新开始,都不会重复传。
- 校验:上传完拿手机返回的摘要和本地比,一致才算成功。
4.2 流式上传,不占内存
swift
let (data, response) = try await session.upload(for: request, fromFile: item.url, delegate: ProgressDelegate(progress))
URLSession.upload(for:fromFile:) 直接从磁盘边读边发,几个 GB 的视频也不会占多少内存。会话的请求超时设成 60 秒,它算的是"多久没有数据进出",不是整个请求的总时长;整体的资源超时则放宽到 24 小时,大文件在慢速 Wi-Fi 上传一两个小时也不会被系统中断。
4.3 一个 URL 编码的坑
文件名是放在 URL 参数里传的。中文文件名、带空格和加号的文件名都很常见,编码时踩了一个坑:
swift
/// 手机端用 URLDecoder 解码,会把 "+" 当成空格,所以除了 ASCII 字母数字以外全部转义。
/// 不能用 CharacterSet.alphanumerics:它包含中文等非 ASCII 字母。
private static let queryAllowed = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
两个问题叠在一起:
- Java 的
URLDecoder遵循的是 HTML 表单编码,会把+解码成空格。a+b.jpg传过去就变成了a b.jpg。所以+必须编码成%2B。 - 偷懒用
CharacterSet.alphanumerics作为"不用编码的字符"也不行,它是按 Unicode 定义的,中文也算字母 ,结果中文被原样塞进 URL。所以干脆手写一个只含 ASCII 字母、数字和-._~的集合,其余全部百分号编码。
4.4 续传是"按文件"的
待传列表实时写进 queue.json,App 退出、Mac 重启都不丢。重新打开后点开始,从第一个"等待中"的文件接着传。
需要说明的是,这里的续传是按文件的,不是按字节断点续传:一个文件传到一半断了,手机上只会留下 .part 文件(下次启动会被清掉),这个文件从头重传。之所以没做字节级断点续传,是因为数据线 24 MB/s 的速度下,即使是 2 GB 的视频重传也就一分多钟,换来的是协议简单很多,也不存在"拼接出错"的可能。配合上面的 sha256 去重,已经传完的文件不会再传。
4.5 传输时不让 Mac 睡着
swift
let activity = ProcessInfo.processInfo.beginActivity(
options: [.userInitiated, .idleSystemSleepDisabled], reason: "PixelPhoto 正在传输")
defer { ProcessInfo.processInfo.endActivity(activity) }
idleSystemSleepDisabled 会阻止系统因为空闲而休眠,传输结束后自动解除,不用去改系统的节能设置。
4.6 删除原件要格外小心
Mac 端有个"传完移到废纸篓"的选项,用来腾空间。它有两条硬规则:
- 只有校验通过的才删:sha256 一致,或者手机上本来就有完全一样的文件;
- "照片"图库里的永远不删:开着 iCloud 照片时,从图库里删一张照片,所有设备上都会被删,后果太大,交给用户自己处理。
而且是"移到废纸篓",不是直接删除,误删了还能找回来。
五、几个数字
| 项 | 值 |
|---|---|
| 数据线(adb forward)实测速度 | 约 24.6 MB/s |
| Wi-Fi(Mac 连 2.4G)实测速度 | 约 2.4 MB/s |
| 手机端读写缓冲 | 1 MB |
| 进度上报间隔 | 每 4 MB |
| 手机预留空间 | 500 MB |
| 连接状态刷新 | 每 3 秒 |
结语
这个工具里没有什么高深的技术,大部分篇幅花在"不能出错"上:流式读写、原子改名、端到端校验、拒绝时断开连接、按内容去重、删原件前再三确认。照片这种东西,丢一张都心疼,宁可慢一点、多算几遍 sha256。
下篇讲另一半:不 root 的情况下,怎么通过 adb 在 Mac 上看到手机的 Google 相册备份进度、CPU 温度和电量,以及怎么逐个扩展名实测"哪些格式手机认、Google 相册会备份"。