从零到 v4.1,经历了一万张照片卡死、管道死锁、滚动条跳回顶部……一个老派网管的 macOS 开发实战记录。


一、为什么要写这个

Mac 用户管理安卓手机文件一直是个痛点。Google 官方的 Android File Transfer 基于 MTP 协议,十年没更新——没搜索、没缩略图、连多选都费劲。想给手机里的文件排个序?对不起,做不到。

市面上也有第三方工具(如 Android Studio 自带的 Device Explorer、各种付费软件),但它们要么太重(装个 IDE 就为了看手机文件?),要么要钱。

我的需求很简单:

  • 插上数据线就能用,手机不装任何 App

  • 能浏览文件、复制粘贴、删除重命名

  • 能看设备信息(电池、存储、系统版本)

  • 能把手机里的 APK 导出来

  • 最好还能看手机屏幕

满足这些条件的现成方案:零。

于是用 SwiftUI + ADB 写了一个。从第一个能显示文件列表的版本到今天 v4.1,前后迭代了四个大版本。开源在 GitHub,MIT 协议。

下载https://github.com/wordwu/android-file-manager/releases


二、功能一览

2.1 文件管理

  • 浏览:列表和图标网格两种视图,支持按名称/大小/日期/类型排序

  • 复制/剪切/粘贴:多选模式,重名自动编号(文件(1).apk

  • 拖拽上传:Mac 文件直接拖进去

  • 下载:快捷键 Cmd+D,下载到 ~/Downloads

  • 重命名:支持单文件重命名和四种批量规则

  • 删除:Delete 键直接删

2.2 搜索

递归搜索,输入即搜(300ms 防抖),搜文件名不搜内容。搜索结果点进去直接定位到文件所在目录。

2.3 设备信息

连上手机后侧边栏点设备名,一口气看:

信息 来源
型号 getprop ro.product.model,内置 40+ 国产机型库自动匹配品牌
Android 版本 getprop ro.build.version.release
电池电量/温度/健康状态 dumpsys battery
存储空间 df -h /sdcard,换算 GB/MB
CPU / 内存 getprop + cat /proc/meminfo
IMEI / 序列号 service call iphonesubinfo

小米、华为、OPPO、vivo、荣耀、一加等国产品牌都能自动识别。

2.4 APK 管理

  • 应用列表:显示所有已安装应用(系统和用户应用分开)

  • 一键导出 APK:选中应用 → 导出 → adb shell pm path 找到 apk 路径 → adb pull 到桌面

  • 批量备份:弹窗全选/反选,进度条显示,一次备份所有想要的 APK

  • 安装 APK:拖个 apk 到窗口即可安装到手机

2.5 屏幕镜像

内嵌 scrcpy v4.0,点一下按钮直接弹窗显示手机屏幕。支持:

  • 鼠标点击 = 手指触控

  • 鼠标滚轮 = 页面滚动

  • 键盘输入(需开启「USB 调试(安全设置)」)

  • 快捷键:Option+H 主页、Option+B 返回、Option+S 最近应用、F11 全屏

scrcpy 直接打进 .app 包里,用户什么都不用装。

2.6 图片预览 + 视频播放

  • 图片:内嵌预览面板,支持常见格式

  • 视频/音频:双击自动 pull 到 Mac 临时目录 → 用系统默认播放器打开,支持 mp4/mkv/avi/mov/mp3/flac 等 20+ 格式

2.7 连接方式

  • USB:插上自动识别,首次需手机上授权「允许 USB 调试」

  • 无线 ADB:USB 连上后点 WiFi 图标 → 开启无线调试 → 拔线,侧边栏出现无线设备


三、架构设计

技术栈:SwiftUI + MVVM + ADB,纯 Swift,零外部依赖(ADB 二进制打进 .app)。

├── App
│   └── AndroidFileManagerApp.swift       # 入口,注入 ViewModels
├── Models
│   ├── FileItem.swift                    # 文件模型
│   ├── Device.swift                      # 设备模型
│   ├── AppInfo.swift                     # 应用信息
│   ├── APKInfo.swift                     # APK 元数据
│   └── TransferTask.swift               # 传输任务状态机
├── Services
│   ├── ADBService.swift                  # ADB 通信核心
│   ├── ADBService+Network.swift          # 设备发现、WiFi 连接
│   ├── ADBService+FileOps.swift          # 文件列表/推送/拉取/搜索
│   └── ADBService+AppOps.swift           # 应用安装/卸载/列表
├── Utils
│   ├── Constants.swift                   # 全局常量
│   ├── ThumbnailCache.swift             # 缩略图缓存
│   └── PreviewLoader.swift              # 文件预览加载
├── ViewModels
│   ├── DeviceManager.swift               # 设备发现与连接
│   ├── FileBrowser.swift                 # 文件导航、排序、过滤
│   ├── ClipboardManager.swift            # 复制/剪切/粘贴
│   ├── SearchManager.swift              # 递归搜索
│   ├── AppListViewModel.swift           # 应用列表
│   └── TransferManager.swift            # 传输队列
└── Views
    ├── ContentView.swift                 # 主布局
    ├── ToolbarView.swift                # 工具栏
    ├── FileListView.swift               # 文件列表/网格
    ├── FileRowView.swift                # 单行文件
    ├── PathBarView.swift                # 路径面包屑
    └── Browser/                          # 预览、详情面板

约 39 个源文件,34 个测试用例。


四、版本迭代

v1.0 — 能看文件就行

第一个版本:USB 连接 → 显示设备 → 点进去看文件列表。功能只有浏览、复制、删除。用 ls -la 读文件列表,没分页,超过几百个文件就开始卡。

v2.0 — 加功能

加入了应用列表、APK 导出、设备信息、无线 ADB。文件操作补齐了剪切/粘贴/重命名。界面上有了工具栏、路径面包屑、侧边栏设备列表。

v3.0 — 重构

代码量上来了,一个 FileBrowser 文件塞了太多东西。做了一次 SRP(单一职责)拆分:FileBrowser / ContentView / TransferManager 各自独立。加了搜索功能,UI 改成了 NavigationSplitView 三栏布局。支持了批量重命名。

v4.0 — 性能修复(2026-06-20)

核心改动:分页加载。 之前的版本在进 Camera 这种一万多张照片的文件夹时直接卡死。原因是 ls -la 在手机上对每个文件执行 stat(),一万个文件 = 一万次 stat,命令耗时 30 秒以上。改了 ls -1aF(只读文件名不做 stat),首屏秒出。加了缩略图 4 并发异步加载,跳过 >5MB 的大文件。

同时加了屏幕镜像(scrcpy 内嵌)、批量 APK 备份弹窗、关于页面(赞赏码+GitHub 链接)。

v4.1 — 极致性能(2026-06-22)

v4.0 解决了「能不能看」的问题,v4.1 解决了「能不能快」的问题。具体遇到的坑见下一节。


五、踩过的坑

5.1 大文件夹卡死:ls -la 的 stat 陷阱

现象:点进 DCIM/Camera(11998 张照片),loading 转圈三分钟,无任何文件显示。

排查:ADB 命令是 adb shell ls -la | head -2050。直觉上 head 应该提前截断——pipe 嘛,读端关了写端就 SIGPIPE。

实际:toybox(Android 上的 busybox 替代品)的 ls -la 在执行任何输出之前,先对所有文件调了 stat()head 退出确实发了 SIGPIPE,但此时 ls 已经 stat 完全部 11998 个文件了。30 秒过去了。然后 ADB 超时 → 重试 → fallback 到 find+stat 又 30 秒。用户看到的就是无限转圈。

修复:改用 ls -1aF,这个命令只读目录项(dentry),不 stat。不管多少文件都是秒出。文件大小等元数据通过 refreshMetadata() 在后台异步补齐。

教训:pipe 的 SIGPIPE 机制只管输出端,不管命令内部的预处理。head 能截断输出,不能截断 ls 的 stat 循环。这个坑在 Stack Overflow 上不会有答案——正常人的文件管理器不需要管一万个文件。

5.2 管道死锁:64KB 缓冲区

现象:缓存 + sed 方案在终端里 0.03 秒出结果,打成 .app 就跑超时。

排查:我把 pageSize 从 2000 调到 5000,sed 输出从 62KB 涨到 155KB。Unix pipe 的默认内核缓冲区是 64KB。写端写到 64KB 时 pipe 满了,sed 进程被挂起等待读端消费数据。而我的 Swift 代码在 process.run() 退出后才调用 readDataToEndOfFile()

一个完美的死锁:读端等写端退出,写端等读端消费,没人动。

修复:在 process.run() 之前启动后台 DispatchQueue 线程,用 readDataToEndOfFile() 提前消费 stdout/stderr。

// 在 process.run() 之前
var stdoutData = Data()
let stdoutQueue = DispatchQueue(label: "adb.stdout")
stdoutQueue.async {
    stdoutData = pipe.fileHandleForReading.readDataToEndOfFile()
}

教训:pageSize=2000 时刚好 62KB 不触发,pageSize=5000 就炸——调试的时候如果你不知道 64KB 这个数,你会在 shell 里反复测命令、怀疑 ADB 版本、甚至怀疑宇宙射线。运维的日常:一个内核数据结构吃掉半小时的命。

5.3 滚动位置跳回顶部:SwiftUI List 的 diff 机制

现象:加载更多后,列表滚动位置跳到最上面。

经历:我前后试了 5 种方案,全失败——

尝试 方案 为什么失败
1 ScrollViewReader + scrollTo List 是 NSTableView 封装,不响应 ScrollViewReader
2 ScrollView + LazyVStack LazyVStack 懒加载,新追加的 item 还没创建,scrollTo 找不到目标
3 AppKit 直操作 NSScrollView 位置恢复了,但 SwiftUI 在恢复后又重建了全部视图
4 删除 _sortDirty 标记 还是跳,List 自己有一套 diff 机制

最终根因:不是 List 的问题,是数据架构的问题。sortedFiles 是计算属性,新数据加入后排序结果全变,ForEach 判定所有元素顺序改变 → 全量重建 → 滚动位置丢失。

修复:把「显示用数据」和「排序用数据」拆开。加了 displayItems 数组,加载更多只做 append,不做排序。列表绑定 displayItems,追加新行不重建旧行。

// 关键:读取 displayItems,只追加不排序
@Published var displayItems: [FileItem] = []
​
func loadMore() async {
    let newItems = await fetchItems(...)
    displayItems.append(contentsOf: newItems)  // 纯追加
}

改完这个之后,前面三套滚动恢复方案全变死代码,删了个干净。

教训:最好的代码是你发现不需要写的代码。5 次失败的方案不是没有价值——它们帮我排除了所有「在 UI 层修」的可能性,逼我回到数据层找根因。

5.4 Swift 6 并发限制

Swift 6 禁止在 async 上下文中使用 DispatchSemaphore.wait()。缩略图加载之前用信号量限流,编译不过。改为 OperationQueue(maxConcurrentOperationCount: 4) 解决。

5.5 patch 工具吃掉字符串插值

Hermes Agent 的 patch 工具在修改 Swift 代码时会吃掉 \(...) 插值符号,变成 \\(...)(双反斜杠)。编译不报错,运行时输出字面量 adb -s \(device)

修复方法:Python bytes.replace 字节级修复,xxd 确认源文件字节码,strings 确认二进制生效。

这条后来写进了开发铁律:改含 \(...) 的 Swift 代码行,不能用 patch,直接用 write_file 重写整个文件。

5.6 Android 16 移除了 content query

v4.1 尝试用 content query 读通话记录,发现在 Android 16 (Baklava) 上这个子命令已被移除。content call --method query 也被 SecurityException 封杀。最后确认 Android 16 上唯一可行方案是安装配套 APK 声明 READ_CALL_LOG 权限。


六、写在最后

这个项目的初衷很简单——市面上没有一个好用的 Mac 安卓文件管理器。Google 的那个 MTP 工具十年没更新,第三方要么收费要么太复杂。

从第一个能显示文件列表的版本到今天,这个项目让我踩了 macOS 开发里能踩的大部分坑:管道缓冲区、Swift 并发限制、SwiftUI 的 diff 机制、ADB 的版本兼容性、Android 16 的 API 变更。也让我确认了一件事:老派网管的那些底子没过时——看日志、查缓冲区、盯字节码、验二进制,这些十年前在酒店机房学的技能,写 SwiftUI 一样用得上。

v4.1 在 GitHub 上了:https://github.com/wordwu/android-file-manager

MIT 开源,有建议直接提 issue。骂也行,别光骂不给 star。


作者:AltairZheng,84 年老网管,做过酒店 IT 维护,转型婚礼搭建,现在用 AI 写 macOS 程序。专栏「老派 IT 生存手册」每周更新。

GitHubhttps://github.com/wordwu

更多推荐