把 Android IDE 的运行时依赖做成清单:PyStudio 包管理踩坑记录
PyStudio 不可能把 Python、pip、debug 工具、tree-sitter、proot、Alpine rootfs、C/C++ 工具链全塞进 APK。App 本体要轻,运行时能力又要能按需安装和更新,这中间必须有一层清单。
这篇记录我最后采用的模型:runtime-packages.json 负责发现能力,Packages.xz 负责包级依赖,planner 负责算安装计划,installer 把计划变成用户能看见的终端脚本。
清单不是另一个 apt。它负责告诉 App 去哪里找能力,真正的依赖关系仍然交给包索引。
为什么不用一堆写死链接
最早的想法很简单:App 里放一个按钮,点了就下载某个包。问题也很快出现:
- 不同 ABI 的包不同:
aarch64、arm、i686、x86_64; - 有些能力不是一个文件,而是一组
.deb依赖; - 同一个 profile 会不断重建,比如
proot从 r40 到 r100; - bootstrap 也需要灵活更新,不能永远内置在 APK;
- 下载源会变:GitHub、Gitee、ModelScope、Cloudflare Pages、自定义域名;
- 安装时用户需要看到 URL、进度、速度、错误,而不是一个转圈。
所以清单必须成为唯一事实来源。App 不推导旧 URL,也不猜包名,而是从清单读取 entry、repository、commands、架构、包池和校验信息。
schema 5 的基本模型
现在的清单是 schema 5,大体分三层:
1 | runtime-packages.json |
一个 package-set entry 不直接等于一个下载链接,而是引用某个架构仓库:
1 | { |
这样 UI 可以展示“这个包会提供哪些命令”,安装器可以从 Packages.xz 解析真正的依赖。
App 里的三层实现
planner 不碰文件系统,只产出安装计划;installer 才负责写脚本和进入终端会话。
Kotlin 侧分成三层。
第一层是 catalog:
1 | RuntimePackageCatalog |
第二层是 planner:
1 | RuntimePackagePlanner |
第三层才是 installer:
1 | RuntimePackageInstaller |
这个分层很重要:planner 不改文件系统,适合以后写测试;installer 只负责把计划变成用户看得见的终端输出。
为什么要在终端里跑安装脚本
用户安装依赖时,最关心的是“到底卡在哪”。如果 App 只是显示一个常驻 installing 状态条,失败时体验很差。
后来我把安装过程改成在终端会话里滚动显示,脚本会打印:
1 | Package: proot |
这比单纯弹 Toast 有用得多。用户能看到哪个 URL 失败、是不是 404、是不是 dpkg 配置失败、是不是命令没出现在 $PREFIX/bin。
一个小坑是 shell heredoc 不能随便缩进。下载脚本里如果 URL 或 heredoc 结束标记前多了空格,shell 可能把后续安装命令都当成输入内容,最后表现成“下载完了但 dpkg 没跑”。这种错误在 UI 上很像安装器卡住,实际是脚本生成格式错了。
真实踩坑:索引发布了,deb 没发布
r100 proot 的清单和 Packages.xz 已经能拉到,元数据也显示:
1 | proot 5.1.107.81-3 |
但安装仍然可能失败,因为真正的 .deb URL 返回 404。这个坑非常典型:
1 | Packages.xz: OK |
也就是说,清单“能列出来”不等于包“能安装”。发布流程至少要检查三类文件:
runtime-packages.json;- 每个 ABI 的
Packages.xz和 metadata JSON; - package pool 里的实际
.deb文件。
我后来在文档里把这个写成发布验收项:App 安装失败时,不要只看 JSON,要抽查最终 .deb 直链。
下载源迁移的经验
清单地址也换过几次,从 Gitee/raw GitHub,到 Cloudflare Pages,再到自定义域名:
1 | https://pystudio.yourba.top/runtime-packages.json |
App 侧这里需要补两件事:默认入口改到新域名,已经保存过旧地址的用户也要自动迁移。否则老用户设置里的 downloadSource 会一直指向旧 URL,即使 APK 已经更新也没用。
类似这样的归一化很实用:
1 | private fun normalizeCatalogUrl(url: String?): String? { |
校验开关不要绑死
开发阶段我一度想把 sha256 验证做成强制项。后来发现更合理的是:清单保留 sha256 字段,但 App 设置里提供“下载校验”开关。
原因很简单:早期包还在频繁重建,清单、镜像、pool 同步可能有时间差。强校验能保证安全,但也会在调试阶段放大同步成本。更好的做法是:
- 默认给普通用户稳定策略;
- 设置里允许打开严格校验;
- 终端脚本把实际 URL、大小、耗时、错误都打印出来;
- 发布流程在 CI 或本地脚本里做完整校验。
不要把调试期的发布不稳定,伪装成 App 安装器的神秘错误。
我现在会坚持的规则
清单只是入口,不是安装结果。发布时必须抽查最终 .deb 文件能不能下载,App 也不应该再去拼旧世界的 URL;包在哪里,由 manifest 和 package pool 说了算。
安装过程要在终端里透明运行。尤其是开发者工具类 App,用户宁愿看到真实错误,也不想看一个不会消失的状态条。bootstrap 也应该进清单,这样 APK 可以保持轻量,rootfs 和 prefix 初始化包可以独立迭代。
命令可用性要来自清单里的 commands 字段和安装后的 verify command。点击运行 Python、tree-sitter、clangd 之前,先检查命令是否存在;不存在就引导安装,不要直接报一屏 shell 错误。
这套方案真正解决的不是“有了一个 JSON”,而是把运行时能力从 APK 里解耦出来,让包、清单、终端输出、设置页和诊断路径连成一条能维护的链路。