Skip to content
Ziwen
Go back

AirJelly 体积优化,976.8 MB → 529.8 MB

目录

最近我们整理了一轮 AirJelly 的打包依赖。9 月 10 日,把 9 月 3 日优化前的源码和最新优化后的源码分别重新构建,macOS arm64 应用的文件体积从 976.8 MB 降到了 529.8 MB,减少 45.8%。用于下载的 DMG 从 323.9 MB 降到了 199.4 MB,减少 38.4%。

大部分收益来自发布包里多带的东西。有些库已经编译进页面,又被完整复制了一遍;有些本地模型运行库挂在可选依赖下面,产品却根本没走那条调用路径。把这些关系理清以后,才轮到压缩主进程 JavaScript。

这两个数字记录的是几次优化和同期代码变化叠加后的净结果。最近一轮单独节省了 33.72 MB,后面会把它拆开。Electron 框架和语言资源在本次比较中保持不变。

AirJelly 应用文件体积从 976.8 MB 降到 529.8 MB。两根柱子使用相同比例尺,灰色的 Frameworks 均为 275.0 MB,蓝色为其余应用文件和资源。DMG 从 323.9 MB 降到 199.4 MB。

先拆开安装包,看体积花在哪里

AirJelly 用 electron-vite 编译代码,再由 electron-builder 组装桌面应用。最终的 .app 里,既有 Electron 框架,也有应用代码、依赖和原生二进制。

应用文件主要集中在 app.asar 和 app.asar.unpacked。前者将许多文件组织成一个归档,后者存放需要以独立文件形式存在的内容,例如部分原生模块。ASAR 清单里也会列出 unpacked 文件,统计时要避免算两遍。Electron 的 ASAR 文档解释了这种归档与外置文件的关系。

测量对象优化前优化后减少
整个 .app976.8 MB529.8 MB447.0 MB
app.asar387.0 MB106.8 MB280.2 MB
app.asar.unpacked313.3 MB146.6 MB166.8 MB
Contents/Frameworks275.0 MB275.0 MB0
DMG 下载文件323.9 MB199.4 MB124.5 MB

表里的项目有包含关系,不能整列相加。这里统一用十进制 MB,1 MB 等于 1,000,000 bytes。.app 按普通文件长度求和,跳过符号链接,因此表示文件内容的体积;APFS 实际分配了多少磁盘空间,要另行测量。

DMG 是另一个有压缩的分发文件,它的降幅和应用文件体积并不相同。对用户而言,这次变化意味着下载少了约 124.5 MB,应用文件少了约 447.0 MB。

框架部分的体积没变,应用内容从 700.4 MB 降到了 253.4 MB。这次的变化集中在应用自己的代码和依赖里。

一个库可能被打包两次

electron-vite 和 electron-builder 处理依赖的方式不同。

electron-vite 可以把库的代码编译进 JavaScript 产物。electron-builder 还会沿生产依赖收集文件,把需要在运行时独立加载的包带上。一个 renderer 库如果已经进入编译产物,却仍列在 dependencies 中,就可能又被复制一份完整目录。

renderer 源码及依赖
  → electron-vite 编译
  → out/ 中的页面和 JavaScript

out/ + 生产依赖目录 + 原生模块 + 运行资源
  → electron-builder 组装
  → 桌面应用

9 月 3 日,我们将 38 个 renderer 库和 3 个构建工具移到了 devDependencies。以 Mermaid 为例,页面使用的代码仍然在 renderer 编译产物里,发布包不必再携带完整的 node_modules/mermaid。

这次复测的旧包里,Mermaid 的独立目录约为 27.3 MB,@phosphor-icons/react 约为 16.7 MB。只看依赖名字,很容易把问题理解成“这个库太大,需要替换”。看完产物,才能发现先去掉重复副本就有收益。

这里的判断要跟着加载方式走。在我们使用的构建配置中,main 和 preload 的生产依赖通常保留为 external,在运行时加载;renderer 依赖以及实际引用的开发依赖可以由构建器编译进去。electron-vite 的依赖处理说明也区分了这些路径。

因此,移动依赖前要确认 main、preload 和 Worker 有没有外置引用。纯 JavaScript 库可以评估内联,SQLite、Sharp 这样的原生模块则需要保留相应的二进制和加载路径。把所有依赖一股脑移进 devDependencies,会得到一个更小、也可能启动不了的应用。

可选依赖会继续带来子依赖

另一块明显的体积来自 LanceDB。

AirJelly 用它在本地存储和检索向量,embedding 由服务端提供。客户端不需要通过 Transformers 在本机运行模型,但当时的依赖关系里包含这一条可选分支。

AirJelly
  ├─ LanceDB
  │    └─ 可选 Transformers
  │         ├─ ONNX 运行库
  │         └─ Sharp 0.33.5 → 旧版 libvips
  └─ Sharp 0.34.5 → 图片处理需要的 libvips

旧包中的 onnxruntime-node 约占 122.7 MB,onnxruntime-web 约占 72.4 MB,Transformers 本身约占 28.8 MB。这些是包内文件内容的大小,不能直接当作 DMG 的下载节省量。

最初,我们用打包文件规则排除了这条路径里的模型运行库。后来继续查包,又看到了旧版 Sharp 和 libvips。文件过滤只决定哪些文件被复制,依赖收集器仍可能沿父包的声明继续遍历,把子依赖提升到发布目录里。

所以这一轮又往前处理了一步。我们给 @lancedb/lancedb@0.31.0 加了一个很小的 Bun patch,从它的包清单中移除未使用的 Transformers 可选依赖声明。这样,收集器就不再沿这条分支找到旧版图片运行库。补丁和 patchedDependencies 一起提交,后续安装会应用同一份修改。Bun patch提供了保存这类依赖修改的机制。

这一步单独减少了约 16.1 MB,LanceDB、当前 Sharp 和当前 libvips 都继续保留。测试还特意在模拟的安装目录里留下旧版依赖,确认收集器不会把它们重新带进包。

这个补丁成立的前提,是产品没有使用 LanceDB 的本地 embedding 路径。以后如果要启用它,就得重新审视这份补丁。依赖升级时也需要复查,不能把某个版本的裁剪结论当成永久规则。

需要保留的库,也不必带上所有文件

还有一类内容没有用错依赖,只是发布得太全。

例如 SQLite 的原生模块需要保留,构建它所用的整套源码却不一定要跟着分发。一个 SDK 已有运行入口,旁边的源码、示例和开发资料,也应该按用途分别检查。

我们收紧了这部分发布规则,对已经确认用途的目录做裁剪,为额外资源列出明确的保留范围,再在 afterPack 阶段检查真实产物。这样,之后升级依赖或调整资源目录,缺少必要文件就能在打包时被发现。

最容易误删的是没有静态 import 的文件。插件可能靠隐藏的 manifest 被发现,Skill 会执行 Python 脚本,Pi 的导出功能会动态读取 HTML、CSS 和 JavaScript 模板。这些都属于运行资源,许可证也需要保留。

因此我们没有使用“删除所有 .ts 文件”或“删除所有脚本目录”这样的全局规则。每条排除规则都限定到已经查过的库,同时检查它的入口和动态资源是否还在。

只用一个日期函数,先让构建器按需编译

主进程里还有一个很小的调用,带来了约 10.2 MB 的完整 date-fns 目录。

format(startTime, "yyyy-MM-dd");

这段代码用本地日期保存应用使用记录。原先 date-fns 作为外置运行依赖发布,即使只调用了一个函数,也要携带完整包目录。

这次保留了 date-fns 和原有调用,把依赖移到 devDependencies,让现有构建器处理静态引用和未使用代码。真正用到的部分随主进程发布,完整目录便不再需要。

更换一个更轻的日期库当然也是选项,但它会引入额外的语义迁移。本地日期尤其容易被写错。toISOString().slice(0, 10) 取得的是 UTC 日期,在部分时区的午夜前后,会把记录归到另一天。

我们直接构建真实的日期处理代码,只隔离数据库、窗口采样和日志这些外部部分,再让编译产物在不同的时区进程中运行。测试覆盖台北跨年、洛杉矶仍处于上一年、闰日和纽约夏令时切换,检查最终保存的日期与时间戳。

业务调用没变,仍需要验证编译后的行为。依赖从外置加载变成内联,改变的是发布路径,测试就应该走到这条路径上。

压缩代码时,把错误定位留下来

多余目录处理完,剩下的 JavaScript 才适合继续压缩。我们给生产 main 构建启用了 esbuild minify,同一配置也覆盖它的独立 Worker 入口。

main: {
  esbuild: {
    keepNames: true,
  },
  build: {
    minify: isDev ? false : "esbuild",
    sourcemap: isDev ? true : "hidden",
  },
}

keepNames 保留函数和类在运行时的 name 值,局部标识符仍然可以缩短。esbuild 的说明解释了两者的区别。保留名称有助于维持依赖名称的行为,具体报错位置则要靠 source map 映射回源码。

我们继续生成隐藏 source map,并通过打包规则排除 .map 文件,保留已有的 Sentry 上传流程。本次验证了压缩位置到源码的映射,没有验证线上上传。

压缩还让一个构建检查报了错。原来的生产身份隐私检查依靠固定函数名和换行格式查找函数体,minify 改变了这些文本形态,检查就无法正确识别。

修复时,我们用已有的 TypeScript 解析器读取 AST,也就是代码的语法结构,通过保留的运行时名称定位函数,仍然严格要求它只返回空对象。额外字段、展开属性和副作用都会被拒绝,异步函数和生成器也不能通过。

安全要求继续执行,检查方式改成了识别代码结构。类似检查如果依赖源码排版,开启压缩时就需要一起核对。

这一轮主进程 JavaScript 从 15.95 MB 降到了 9.50 MB。这里包含 date-fns 内联后的变化,不能把全部差值都归给 minify。

每一步省了多少,要单独记账

前面 447.0 MB 的累计变化跨过了两个历史源码快照,中间还有其他功能和依赖调整。它适合回答最终应用小了多少,无法独立证明某一个改动的贡献。

最近一轮保留了分阶段的审计包,可以在更窄的范围里比较。

阶段app.asar 与 unpacked 合计本步减少
本轮开始前287.07 MB不适用
断开未使用的 Transformers 依赖分支271.02 MB16.05 MB
date-fns 按需编译并压缩 main253.35 MB17.67 MB

三项改动合计节省 33.72 MB。后一步的 17.67 MB 除了日期库目录和 JavaScript 的变化,还包括约 1.02 MB 的 ASAR 索引缩减。文件变少,记录路径和校验信息的元数据也会随之变少。

最终比较用两个独立 Git worktree,分别按各自的锁文件安装和构建。两端使用相同的 macOS arm64 平台,以及 Electron 41.7.1、electron-builder 26.8.1、electron-vite 5.0.0 和 Vite 7.3.3。原生模块版本及已有补丁核对一致后复用,OCR 和辅助功能二进制按各自源码重新构建。两份 DMG 都未签名、未公证,用于体积测试。

复测时,原生资源准备不能省略。安装时跳过脚本、打包时关闭 rebuild,如果没有补齐二进制,就可能拿到一个缺文件的“小包”。我们保留了源码提交、锁文件和产物哈希,用来识别样本;构建身份及 DMG 元数据仍可能变化,这次没有做到逐字节可重复构建。

本地 macOS 的 2,765 个单元与集成用例通过,两份 DMG 均通过完整性检查。新包还通过了 30 项模块与 Worker 检查,覆盖 SQLite 加密读写、Sharp 图片处理、LanceDB 向量操作,以及动态模板和 Worker 执行。220 个语言资源文件的 SHA-256 完全一致,约 48.4 MB 的语言文件全部保留。

这些检查证明了本次测量和所覆盖的运行路径。Windows、macOS x64、签名公证及完整桌面流程仍需要分别验收,体积变小本身不能替代发布测试。

剩下的体积有不同的成本

优化后,275.0 MB 的 Contents/Frameworks 已占整个应用约 51.9%。继续追求同样幅度的下降,面对的成本就不同了。

如果继续保留现有 Electron 架构和全部语言资源,下一轮就得在剩余的应用内容里寻找空间。是否值得继续裁剪某个依赖,要看新的文件清单,以及它会增加多少升级和运行验证的工作,不能沿用这次的节省比例来预估。


Share this post:

Previous Post
Agent 如何形成长期记忆
Next Post
Codex Compact 怎样跨过上下文边界