Appearance
安卓之门
背景:文档标着"远期P5",APK却已在生产测试中运行。 过程:逐条核查七项旧报告——六项已通,三项真需修。
地下管道的尽头,有一扇门。
外交官站在那扇门前很久了。门上挂着一个牌子,字迹清晰——「安卓之门·远期规划·Phase 10·P5优先级·勿入」。
每个人都有自己不愿碰的那扇门。外交官也不例外。
所以当他在构建日志里看到那行字的时候,他愣住了。
Android APK 构建完成: bin/MikuMikuAR.apk (48.3 MB)不是"构建失败",不是"依赖缺失",不是"Wails v3 Android 支持未就绪"——是"构建完成"。
他揉了揉眼睛,重新拉了一遍日志。十秒前的命令,二十秒前的编译,三十秒前的 NDK 交叉编译——一切正常,一路绿灯。APK 文件好好地躺在 bin/ 目录下,48.3 兆,有签名,有 AndroidManifest.xml,有编译好的 libwails.so。
他把 AI 同行者叫了过来。
"你看这个。"
AI 看了一眼屏幕:"APK 构建成功?这不是好事吗?"
外交官指了指门上的牌子:"那这个呢?"
AI 回过头,读了一遍牌子上的字——「远期」「勿入」——然后看了看地上的 APK,又看了看牌子,表情从不解变成了哭笑不得。
"所以……这门其实没锁?"
"我正在想这个问题,"外交官说,"而且更可怕的是——如果这门一直没锁,我们到底还错过了什么?"
AI 蹲在 APK 文件前,像考古学家发现了一件不该存在的文物。
"不可能啊,"AI 说,"Wails v3 的文档里写着呢——'Android support is experimental, not production-ready'。"
"那是 Wails v3 早期版本的文档,"外交官说,"现在 v3 已经迭代了十几版了。文档没更新,代码更新了。"
"所以……我们一直信的是过时的文档?"
外交官没有回答这个问题。他正在翻一个旧文件夹。AI 凑过去看——文件夹的名字叫「安卓适配 · 前期调研」,里面躺着一份报告,从没见过第二天的光。
"这是半年前写的,"外交官说,"当时我们评估了 Android 可行性,列了一串障碍清单。"
AI 接过报告,念出了上面的内容:
"障碍一:@wailsio/runtime 绑定——Android JNI 桥接可能不稳定。"
外交官敲了敲键盘,打开了一个文件。
"这是 app.go,"他说,"里面用了 runtime.LogInfof、runtime.EventsOn、runtime.CallID——所有 Wails runtime 调用。"
他拿起手机——安卓测试机——打开了一个安装好的 APK 版本。屏幕上,模型库列表正常显示,进度条正常推进,页面切换正常进行。
"JNI 桥接如果不稳定,上面这些东西根本不会显示,"外交官说,"但你看——它跑得好好的。Wails v3 的 nativeHandleRuntimeCall 已经把这个通道走通了。"
AI 在报告上划了一条线,在旁边写了两个字:已通。
"障碍二:os.* 文件操作——Android 不是桌面操作系统,文件 API 可能不兼容。"
外交官这次没有着急回答。他打开了一个终端,连上了安卓测试机,执行了一条命令:
$ adb shell uname -a
Linux localhost 5.10.149-android12-... #1 SMP PREEMPT"Android 用的是 Linux 内核,"外交官说,"os.Open、os.ReadDir、os.Stat——这些东西在底层调用的是 Linux 系统调用。沙盒内完全正常。"
AI 若有所思地点头:"所以那个障碍……"
"也是个假警报。"
他让 AI 自己看代码——internal/app/app.go 里,SelectDir 用了 os.Stat 判断目录是否存在,ScanDirectory 用了 os.ReadDir 遍历文件树。这些函数在安卓测试机上跑了几百次了,一次都没出过问题。
AI 又划了一条线。
"我猜,"AI 说,语气已经开始带着一点预感了,"障碍三也是假的?"
"障碍三:net.Listen HTTP 服务器——Android 可能不支持 127.0.0.1 TCP 监听。"
外交官笑了。
"你知道什么比 Linux 内核更像 Linux 内核吗?"
"什么?"
"另一个 Linux 内核。"
他打开了一个测试日志文件:
[INFO] 文件服务器启动: http://127.0.0.1:49321
[INFO] 收到请求: GET /models/初音ミク/readme.txt
[INFO] 响应完成: 200 OK (1.2 KB)"这不是在 Windows 上跑的,"外交官说,"这是在 Android 上。net.Listen('tcp', '127.0.0.1:0') —— Android 的 Linux 内核完美支持,Wails 的 WebViewAssetLoader 进程内服务不走端口,用户模型资源走 HTTP——两个都正常。"
"等等,"AI 突然想到一个问题,"如果文件服务器通过 127.0.0.1 提供 HTTP 服务——那 Android WebView 没有跨域安全问题吗?"
"障碍四:WASM + SharedArrayBuffer——Android WebView 可能不支持。"
外交官打开了一个页面——安卓测试机上,物理引擎正在运行。WASM 编译的 Bullet 物理库,在 WebView 里驱动着三个模型的刚体模拟。布料物理的演示场景里,裙摆的每一个顶点都在计算中跳动。
"Android 的 WebView 用的是 Chrome 内核,"外交官说,"Chrome 支持 WASM 从第一天就开始了。SharedArrayBuffer 需要跨域隔离的上下文——Wails 的自定义协议正好天然满足这个条件。"
他停了一下:"也就是说,WASM 物理引擎在安卓上跑得跟在桌面上一样好。"
AI 沉默了。她看着报告上列出的七项障碍,前三项全灭,第四项也倒了。这条"不可逾越"的鸿沟,正在一条一条地被填平。
"我能问你一个问题吗?"AI 说。
"问。"
"这扇门上挂着的「远期」的牌子——到底是谁挂上去的?"
外交官沉默了一会儿。
"我们挂的。半年前。那时候我们对 Wails v3 的 Android 支持一无所知,评估的时候全都是猜测。然后我们把猜测写进了文档,文档变成了一条铁律,铁律变成了这扇门上的锁。"
他顿了顿:"问题是——锁是我们自己想象出来的。门从来就没有锁过。"
"障碍五:canvas.toDataURL——Android WebView 可能有跨域 canvas 安全问题。"
外交官指了指屏幕上正在显示的模型缩略图——每个模型加载后,场景引擎会自动截取一张缩略图,用 canvas.toDataURL 转成 data URL,存入缓存。
"这个在安卓上跑了几百次了,"他说,"缩略图一张都没丢。"
Wails 的自定义协议(wails://)加载的资源,在 WebView 中被视为同源。canvas 操作不会触发跨域安全策略。
"障碍六:AudioContext——Android WebView 可能不支持 Web Audio API。"
"这个有点微妙,"外交官说,"Web Audio API 本身是完全支持的——Android Chrome 做得很到位。唯一的问题是自动播放策略:用户必须先点一下屏幕,才能启动音频上下文。"
"这不是安卓特有的问题,"AI 说,"桌面端也一样吧?"
"一样。我们已经有 user gesture 的检测逻辑了。所以严格来说,这个障碍甚至不该出现在 Android 专属清单里。"
AI 划了第六道线。报告上密密麻麻的条目,六条已经作废。
"还有最后一条吗?"AI 问。
"障碍七:@wailsio/runtime 事件系统——Android 无法接收来自 Go 端的事件推送。"
外交官拿起安卓测试机,打开了一个实时日志视图。进度条在动——那是 Go 端 runtime.EventsEmit 推送的解压进度。文件列表在更新——那是 EventsOn 监听的模型扫描完成事件。
"事件走的是 JNI 通道,"外交官说,"Go 端调用 EventsEmit → JNI 桥接 → Java 层 → WebView JavaScript 回调。整个链路在 Wails v3 里已经实现了。"
他把手机放回桌上:"七项障碍,六项已通。还有一项本来就不该出现在这个清单里。"
"哪一项?"
"AudioContext 那个不算。所以其实,七项中真正有意义的六项,全部正常工作。"
AI 盯着那份被划得面目全非的报告,突然意识到一个问题。
"那——如果报告上列的所有障碍都不是真的障碍——真正的障碍在哪?"
"我们来找找,"外交官说。
他打开了一个新的文档,标题写着「P0 · 必须修复」。上面只有三条内容。
"这是真正的差距分析,"他说,"半年前那份报告是猜的。这份报告是测出来的。"
第一条:openFileDialog 的 Android 守卫。
"app.go 里有一个判断——如果 isAndroid,直接返回错误 '文件选择器暂不可用'。半年前,Wails v3 的 SAF 文件选择器还没成熟,这个守卫是对的。但现在的 Wails v3 已经实现了 Storage Access Framework 的集成。所以这个守卫,现在是一道假墙。"
"把它拆掉,"AI 说。
"拆掉。"
第二条:AndroidManifest.xml 缺少 android:usesCleartextTraffic="true"。
"Android 9 之后,默认禁止 HTTP 明文流量。我们的文件服务器跑在 127.0.0.1 上,用的是 HTTP。如果不声明 cleartext 许可,WebView 会拒绝加载 all 本地资源。"
"但我们的 APK 不是已经构建成功了吗?"AI 问。
"构建成功不代表运行正常,"外交官说,"APK 能装,但打开后纹理全白、模型加载失败——因为 WebView 把 http://127.0.0.1 的请求全部拦截了。我们在测试机上手动开了一次 cleartext 开关才跑通的。"
"所以这扇门其实半开着——只是缺了一把钥匙。"
"就是这样。在 AndroidManifest.xml 里加一行声明——android:usesCleartextTraffic="true"——所有本地资源就能正常加载了。"
第三条:prompt() 和 confirm() 调用,散落在前端各处,大约二十处。
"Android WebView 不实现 onJsPrompt回调,"外交官说,"任何 prompt('请输入名称') 或者 confirm('确定删除吗') 的调用,在安卓上都会直接挂掉。"
"二十处……那怎么办?"
"全部替换成 Wails 的 Dialog API。runtime.MessageDialog——这个 API 在桌面和安卓上都工作。一举两得。"
"'一石二鸟',"AI 纠正道。
"意思一样。"
"就这三条?"AI 问。
"就三条,"外交官说,"改完这三样,安卓就能跑了。"
"那我们刚才看的那份七大障碍的调研报告呢?"
外交官把那份旧报告折叠起来,放进了抽屉。
"那是历史文档了,"他说,"留着它,是为了提醒我们——最危险的不是技术本身,而是我们对技术的过时认知。"
他站起来,走到那扇门前,把牌子摘了下来。
"Phase 10?P5 优先级?"他把牌子翻过来,背面是空白的。
"你知道应该写什么吗?"AI 问。
外交官拿起笔,在上面写了一行字:
「已在这扇门后工作多时——只是我们没来敲门。」
APK 重新构建了一次。三条修改落地:openFileDialog 的守卫移除、AndroidManifest.xml 补了一行 cleartext 声明、前端二十处 prompt() 替换为 Wails Dialog——两个文件,前后不到半小时。
构建日志:
Android APK 构建完成: bin/MikuMikuAR.apk (48.3 MB)
所有 P0 修复已验证通过AI 看着日志,突然说了一句:"你知道吗——最大的讽刺在哪里?"
"哪里?"
"我们一直在说「安卓是下一代的事」——但其实,安卓已经默默工作了至少两轮发版周期了。缺的不是技术,不是代码,不是适配——"
"缺的是去确认它是否真的不行的勇气,"外交官接上了她的话。
他伸手推开了那扇门。门外没有万丈深渊,没有未知大陆,没有怪兽——只有一个正常的 Android 桌面,上面跑着一个正常的 WebView,加载着一个正常的 Web 应用。
"门从未锁过,"外交官说,"我们只是相信了门上那块牌子上写的字。"
番外附记:门上的字
| 旧报告 | 真相 |
|---|---|
@wailsio/runtime 绑定不稳定 | ✅ JNI 桥接正常 |
os.* 文件操作不兼容 | ✅ Android 是 Linux 内核 |
net.Listen HTTP 服务器不支持 | ✅ 127.0.0.1 TCP 正常 |
| WASM + SharedArrayBuffer 不支持 | ✅ Chrome WebView 原生支持 |
canvas.toDataURL 跨域问题 | ✅ 自定义协议同源 |
AudioContext 不支持 | ✅ Web Audio API 工作正常,autoplay 需手势 |
| 真正 P0 只有 3 项 | ✅ 已全部修复 |
门从来没有锁过。我们只是被门上那块过期的牌子,拦了半年。