把旧 Pixel 变成相册备份中转站(上):Mac 到安卓的照片传输工具设计——流式上传、sha256 校验、adb forward 与 Bonjour

说明:这个工具是和 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。原因有两个:

  1. 相册永远看不到半截文件。以点开头的是隐藏文件,媒体库不会去扫;改名在同一个目录里完成,是原子操作,要么还是 .part,要么已经是完整的文件。Google 相册只会备份完整的文件。
  2. 断了好清理 。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-._~")

两个问题叠在一起:

  1. Java 的 URLDecoder 遵循的是 HTML 表单编码,会把 + 解码成空格。a+b.jpg 传过去就变成了 a b.jpg。所以 + 必须编码成 %2B。
  2. 偷懒用 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 相册会备份"。

相关推荐
墨天梦1 小时前
B08_Fragment事务通信与双生命周期
android
Kapaseker1 小时前
Android 以后可能不会再有横竖屏适配了
android·kotlin
墨天梦2 小时前
B13_通知图片相机与影音
android·数码相机·kotlin
事圆则缓2 小时前
Android 一次性事件怎么设计:Channel、SharedFlow 还是 UI State?
android·ui
东坡肘子3 小时前
Swift Server,又多了一个 Google -- 肘子的 Swift 周报 #156
人工智能·swiftui·swift
Android系统攻城狮15 小时前
Linux Gstreamer深度解析之gst_audio_encoder_set_frame_max调用流程与实战(六十一)
android·linux·运维·音视频·gstreamer音视频·音视频进阶·gstreamer音视频进阶
传奇开心果编程18 小时前
【Compose Multiplatform 跨端开发学与练】第7课 平台适配与互操作
android·windows·学习·ui·ios·kotlin·composer
mmsx18 小时前
Android 地图数据链路:从 GDAL、KML 到多引擎适配
android·kotlin
2601_9689007718 小时前
大模型版本回归评估实战:从能力指标到额度口径的完整链路
android·数据挖掘·回归