11 月 10 号晚上九点多,上海市金山区,外面下雨。君陌工具箱的 Windows 版早就稳定了,Mac 版也过了签名那关,能双击打开。我想着收个尾,把 dmg 打出来,明天拷给两个朋友测一轮。
打包命令跑完,我在 dist 目录里看到那个 dmg,右键显示简介,体积那一栏写着 197.3 MB。
愣了一下。前一天单独出的 arm64 包才 101 MB,怎么合一下就快翻倍了。打开一查,两个架构的 Chromium 框架各塞了一份,资源目录里还躺着一堆我根本用不上的东西。
这篇就把这一晚上折腾明白的事写下来:架构到底怎么选、体积都长在哪、哪几刀能砍、dmg 怎么做得像个正常软件。
一、先说架构:universal 还是分两份出
Mac 现在有两个架构:Intel 机器的 x86_64,和 M 系列的 arm64。electron-builder 有三个选择:
| 命令 | 产物 | 体积(我这份项目实测) | 适合谁 |
|---|---|---|---|
--mac --arm64 | 只有 M 系列能跑 | 101 MB | 只给自己和朋友的新 Mac 用 |
--mac --x64 | 只有 Intel 能跑 | 103 MB | 确定用户都是老机器 |
--mac --universal | 一份包通吃 | 197 MB | 不知道对方用什么机器 |
universal 不是「优化过的双架构」,它是把两个完整的 .app 用 lipo 拼接成一个 Fat Binary。Electron 里最占地方的是 Chromium 框架(Electron Framework.framework),这个框架本身有两套二进制,合起来就是实打实的两倍。
所以体积翻倍不是 bug,是物理规律。想省,只有一个办法:别出 universal。
我最后选了分两份出。原因很实际:手上那台 2014 款 Intel Mac mini 只是拿来当服务器测,真要给人用的都是 M 系列。分两份出,arm64 那份 101 MB,微信传起来也快。
真要 universal 也行,前提是项目里没有只支持单架构的原生模块(node-gyp 编译出来的 .node 文件)。纯前端的小工具一般没事,一旦用了 sqlite3、sharp 这类带原生二进制的包,universal 打包会在合并阶段直接报错。
二、体积都长在哪:把 197 MB 拆开看
我在 dmg 挂载出来的 .app 上跑了一遍 du -sh,按目录拆:
| 目录 | 占多少 | 是什么 | 能不能砍 |
|---|---|---|---|
| Frameworks/Electron Framework.framework | 约 130 MB(universal) | Chromium 本体 | 不能,除非换 Tauri |
| Resources/*.lproj | 约 26 MB | 内置的五十多种语言包 | 能,一刀省 20 MB |
| Resources/app.asar | 12 MB | 你自己的代码和资源 | 能,看你怎么打 |
| Resources/app.asar.unpacked | 看情况 | 原生模块、二进制文件 | 能,别把整个 node_modules 塞进去 |
最冤的是第三行。Electron 默认带一整套语言本地化文件,阿拉伯语、保加利亚语、捷克语……五十多种。一个中文工具站的应用,这些文件一个都用不上,但每次打包都老老实实带上。
三、砍体积的四个动作
1. 删掉用不上的语言包
electron-builder 里加一个 afterPack 钩子,打包完成后删掉除中英文以外的 lproj 目录。这一步能省 20 MB 上下,是性价比最高的一刀。
// scripts/afterPack.js
const fs = require('fs')
const path = require('path')
exports.default = async function (context) {
const appPath = context.appOutDir
const app = fs.readdirSync(appPath).filter(n => n.endsWith('.app'))[0]
if (!app) return
const resDir = path.join(appPath, app, 'Contents', 'Resources')
const keep = ['zh_CN.lproj', 'zh_TW.lproj', 'en.lproj', 'Base.lproj']
for (const name of fs.readdirSync(resDir)) {
if (name.endsWith('.lproj') && !keep.includes(name)) {
fs.rmSync(path.join(resDir, name), { recursive: true, force: true })
}
}
}
配在 build.mac.afterPack 里指到这个文件就行。删完要重新双击测一遍,确认菜单栏和右键菜单还是正常中文——我这边删完没出问题。
2. files 写白名单,不要写黑名单
files 字段里写排除规则是最容易翻车的地方。你写了排除 A,结果 A 的依赖还在;漏一个,包就大一圈,而且你根本看不出来。
我现在的做法是反过来,只写要带的东西:
"files": [
"dist/**/*",
"main/**/*",
"preload/**/*",
"package.json",
"!**/*.map",
"!**/node_modules/*/{test,docs,example}/**"
]
注意最后一行。很多 npm 包会把测试用例、文档、示例一起发出来,node_modules 里这种垃圾能占到整个 asar 的三分之一。
3. asar 保持开启,但把该 unpack 的挑出来
asar 默认是开的,它把几千个小文件打成一个包,能显著减少文件系统开销。但原生模块(.node、.dylib)和需要外部读取的二进制不能塞进 asar,必须写进 asarUnpack,否则运行时读不到。
"asarUnpack": [ "**/*.node", "**/resources/bin/**" ]
4. 压缩级别别拉到顶
electron-builder 的 dmg 默认用 zlib 压缩,compression 设成 maximum 能再小几兆,但打包时间会翻倍。我试过一次,197 MB 压到 189 MB,多花了六分钟,不值。
保持 normal 就行了。
四、dmg 怎么做才像个正常软件
第一次出的 dmg 我都不好意思给人:挂载打开,里面孤零零一个图标,位置还偏在右上角,用户根本不知道要拖到哪。
正经的 dmg 应该有这些:
- 一张背景图,告诉用户「把左边拖到右边」;
- 一个 Applications 文件夹的软链接,拖进去就是安装;
- 固定的窗口尺寸和图标位置,每次打开都一样;
- 一个新的 icns 图标,不是 Electron 默认那个。
前三条都在配置里写:
"dmg": {
"title": "君陌工具箱 ${version}",
"background": "build/dmg-bg.png",
"icon": "build/icon.icns",
"window": { "width": 560, "height": 360 },
"contents": [
{ "x": 150, "y": 180, "type": "file" },
{ "x": 410, "y": 180, "type": "link", "path": "/Applications" }
]
}
背景图做 560×360 就行,中间留空,左右两边画两个虚线框。图标位置对上了,用户一眼就知道怎么操作。
图标这块要单独提:icns 必须是多尺寸打包的。只放一张 1024 的 png 进去,Dock 里显示会糊。macOS 上可以这么生成:
mkdir icon.iconset sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png # ... 32/64/128/256/512 各来两套 iconutil -c icns icon.iconset -o icon.icns
还有一点:dmg 里不能有 .DS_Store 和多余的隐藏文件,否则用户按 Cmd+Shift+. 一看,一堆乱七八糟的东西。打包脚本里顺手 rm -f 掉。
踩过的两个 dmg 坑
第一个是背景图尺寸。我第一版背景图随手拿了张 1200×800 的图,配的窗口是 560×360。结果挂载打开,背景被裁得只剩中间一块,两个虚线框一个都看不见,图标位置全跑偏。后来才明白:window.width/height 是多少,背景图就得按这个尺寸出,而且必须再配一张 @2x 的,否则 Retina 屏上糊成一片。
第二个是图标变成问号。dmg 里那个 App 图标显示成一个问号方块,点也点不动。原因是 icon.icns 的路径写成了相对路径,打包时没被收进 dmg。改成绝对路径相对项目根目录(build/icon.icns),重新出包就好了。
顺带说说 zip 和 pkg
除了 dmg,我还会额外出一个 zip。理由很实际:dmg 要挂载、要等磁盘映像加载,在别人的机器上偶尔会卡住;zip 解开就是一个裸 .app,拖进 Applications 立刻能用。给朋友远程救场的时候,zip 比 dmg 快得多。
pkg 我没做。那是给需要写到系统目录、装后台服务的应用用的,一个纯本地小工具用不上,反而会让用户多走一遍安装向导。
五、一份能直接抄的配置
把上面这些拼起来,package.json 里的 build 段大概是这样:
"build": {
"appId": "com.moxiaoa.toolbox",
"productName": "君陌工具箱",
"directories": { "output": "dist", "buildResources": "build" },
"files": [
"dist/**/*", "main/**/*", "preload/**/*", "package.json",
"!**/*.map"
],
"asarUnpack": ["**/*.node", "**/resources/bin/**"],
"compression": "normal",
"mac": {
"category": "public.app-category.utilities",
"icon": "build/icon.icns",
"target": [
{ "target": "dmg", "arch": ["arm64"] },
{ "target": "zip", "arch": ["arm64"] }
],
"hardenedRuntime": true,
"gatekeeperAssess": false,
"afterPack": "scripts/afterPack.js"
},
"dmg": {
"title": "君陌工具箱 ${version}",
"background": "build/dmg-bg.png",
"window": { "width": 560, "height": 360 },
"contents": [
{ "x": 150, "y": 180, "type": "file" },
{ "x": 410, "y": 180, "type": "link", "path": "/Applications" }
]
}
}
出包命令就一句:
npx electron-builder --mac --arm64
要 Intel 版本,把 arm64 换成 x64 再来一遍。两边的产物分别在 dist/mac-arm64 和 dist/mac,别搞混。
六、出包之后先验这四件事
- 架构对不对:
lipo -info 路径/君陌工具箱.app/Contents/MacOS/君陌工具箱,输出里要有 arm64 或 x86_64; - 签名在不在:
codesign -dv --verbose=2 那个.app,看到Signature=adhoc就说明签名那步生效了; - 体积变没变:
du -sh dist/mac-arm64/*.app,删过语言包的话应该比上一版小二三十兆; - 拷到另一台机器双击:这条最实在。同一台机器上永远测不出 quarantine 的坑。
我这轮就是漏了第四条。在打包机上双击好好的,拷到另一台 Mac 上直接弹「已损坏」——也就是上一篇写的那个坑。
七、三条经验
1. 体积翻倍先查架构,别怀疑代码
看到包突然变大,第一反应常常是「是不是 resources 打多了」。先跑 lipo -info,十秒就能确认是不是 universal 干的。我那天翻半天目录,最后发现是架构问题。
2. dmg 的体验比体积重要
197 MB 和 189 MB 对用户来说没区别,但「拖进 Applications」这件事有没有做,体验差得很远。别在压缩上抠那几兆,把 dmg 的窗口、背景、软链做对。
3. 出一份 arm64、一份 x64,比 universal 省心
除非你确定用户群里有大量 Intel 老机器,分两份出永远更划算:体积小、打包快、原生模块不打架。代价是多跑一条命令,多传一个文件,如此而已。
八、最后
君陌工具箱目前 26 个工具,全部本地运行、断网可用,数据不出本机,v1.0 还在内测阶段,暂不对外提供。这篇写的是把它打成 Mac 包时踩的体积和 dmg 坑,跟软件本身的功能没关系。
打包、签名、分发这块有想聊的,站内交流,微信:lige0552。