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.asar12 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 应该有这些:

  1. 一张背景图,告诉用户「把左边拖到右边」;
  2. 一个 Applications 文件夹的软链接,拖进去就是安装;
  3. 固定的窗口尺寸和图标位置,每次打开都一样;
  4. 一个新的 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,别搞混。

六、出包之后先验这四件事

  1. 架构对不对:lipo -info 路径/君陌工具箱.app/Contents/MacOS/君陌工具箱,输出里要有 arm64 或 x86_64;
  2. 签名在不在:codesign -dv --verbose=2 那个.app,看到 Signature=adhoc 就说明签名那步生效了;
  3. 体积变没变:du -sh dist/mac-arm64/*.app,删过语言包的话应该比上一版小二三十兆;
  4. 拷到另一台机器双击:这条最实在。同一台机器上永远测不出 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。