主题
苹果M1芯片打包适配
WHY
距离 2020 年 11 月 11 日 苹果 M1 芯片发布,已经一年多,目前 Mac 平台我们只发布了 Intel 的版本,用户在 M1 的 MacOS 上运行 Intel 的版本时系统会自动使用 Rosetta 2 转译后运行,即使已经优化的很好,但是还是有很大的性能损耗。
M1 打包
electron-builder --mac
启动时间测试
测试平台
MacBook Pro Intel 2020
CPU Intel i7
内存 16 GB
MacBook Pro Apple 2020
CPU Apple M1
内存 16 GB
启动时间统计
表格
分析
对比结果
- M1 机器运行 M1 版应用对比 Intel 机器运行 Intel 版应用快 37.75%;
- M1 机器运行 M1 版应用对比 M1 机器运行 Intel 版应用快 113.10%;
- M1 机器运行 Intel 版应用冷启动需要转译,会花费 11 秒左右;
- M1 机器运行 M1 版应用冷启动对比 M1 机器运行 Intel 版应用冷启动快 227.68%。
通用包打包
electron-builder --universal --mac mas
调研
首先 M1 芯片是基于 arm64 架构的,Intel 芯片是基于 x64 架构的,运行的系统、程序、二进制文件也应该是对应的架构的。后续会用 arm64 和 x64 代替 M1 和 Intel。
electron-builder
macPackager 的 doPack 在打通用包时,调用 super.doPack 也就是 platformPackager 的 doPack 分别生成了 x64 和 arm64 两种架构的文件夹,然后使用 @electron/universal 进行合并操作。
js
case Arch.universal: {
const x64Arch = Arch.x64
const x64AppOutDir = appOutDir + "--" + Arch[x64Arch]
await super.doPack(outDir, x64AppOutDir, platformName, x64Arch, platformSpecificBuildOptions, targets, false, true)
const arm64Arch = Arch.arm64
const arm64AppOutPath = appOutDir + "--" + Arch[arm64Arch]
await super.doPack(outDir, arm64AppOutPath, platformName, arm64Arch, platformSpecificBuildOptions, targets, false, true)
const framework = this.info.framework
log.info(
{
platform: platformName,
arch: Arch[arch],
[`${framework.name}`]: framework.version,
appOutDir: log.filePath(appOutDir),
},
`packaging`
)
const appFile = `${this.appInfo.productFilename}.app`
const { makeUniversalApp } = require("@electron/universal")
await makeUniversalApp({
x64AppPath: path.join(x64AppOutDir, appFile),
arm64AppPath: path.join(arm64AppOutPath, appFile),
outAppPath: path.join(appOutDir, appFile),
force: true,
mergeASARs: platformSpecificBuildOptions.mergeASARs ?? true,
singleArchFiles: platformSpecificBuildOptions.singleArchFiles,
x64ArchFiles: platformSpecificBuildOptions.x64ArchFiles,
})
await fs.rm(x64AppOutDir, { recursive: true, force: true })
await fs.rm(arm64AppOutPath, { recursive: true, force: true })
// Give users a final opportunity to perform things on the combined universal package before signing
const packContext: AfterPackContext = {
appOutDir,
outDir,
arch,
targets,
packager: this,
electronPlatformName: platformName,
}
await this.info.afterPack(packContext)
await this.doSignAfterPack(outDir, appOutDir, platformName, arch, platformSpecificBuildOptions, targets)
break
}
afterPack
是electron-builder里可以配置的一个选项,是我们成功打出通用包的关键点。
js
if (framework.afterPack != null) {
await framework.afterPack(packContext);
}
@electron/universal
@electron/universal 会比对两个架构的文件夹里的文件,使用 file 命令获取文件类型,判断是否是 Mach-O 文件还是其他的文件,对 Mach-O 文件会调用 lipo 命令合并成通用二进制格式,其他文件也会根据规则进行合并操作。然后产生 app 文件夹,继续进行后续的操作。
file 命令 获取文件类型
fileOutput = await spawn('file', ['--brief', '--no-pad', filePath]);
Mach-O 文件 苹果的二进制文件格式
二进制文件格式
通用二进制格式
lipo 命令 合并通用二进制格式
await spawn('lipo', [ first, second, '-create', '-output', outputPath ]);
方案
先打一个 arm64 的包,然后在 x64 环境下打通用包,在 arm64 的 afterPack 脚本中替换 Mach-O 文件,以便 @electron/universal
合并成通用二进制格式。
js
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const fs = require('fs-extra');
const builder = require('electron-builder');
const { Arch } = builder;
exports.default = async function (context) {
// 通用包并且非当前架构
if (context.appOutDir.includes('universal') &&
((process.arch === 'arm64' && context.arch === Arch.x64) ||
(process.arch === 'x64' && context.arch === Arch.arm64))
) {
await copyDiffArchResources(context);
}
};
/**
* 通用包打包第一步会生成x64包和arm64包,但非当前架构的包有问题
* 更新非当前架构的包的MachO文件(.node,.dylib)为非当前架构的文件
* 把非当前架构的包放到本机"~/work"目录下
* @param {*} context
*/
async function copyDiffArchResources(context) {
const arch = process.arch === 'arm64' ? 'x64' : 'arm64'
console.log(`copy ${arch} resource`);
const sourceContentsPath = path.join(os.homedir(), 'work', '有道云笔记.app', 'Contents');
const targetContentsPath = path.join(context.appOutDir, context.packager.appInfo.productFilename + '.app', 'Contents');
const resources = [
'dll/scholar/client.dylib',
'Resources/app.asar.unpacked',
'Resources/build/sqlite3-fts5/libsimple.dylib'
];
const ignores = [
'fsevents',
];
for (let resource of resources) {
await fs.copy(path.join(sourceContentsPath, resource), path.join(targetContentsPath, resource), {
overwrite: true,
filter: (src) => {
if (fs.statSync(src).isDirectory()) {
if (ignores.some(el => src.includes(el))) return false
return true;
}
const fileOutput = spawnSync('file', ['--brief', '--no-pad', src], { encoding: 'utf8' }).stdout.replace(/\n$/, '');
const res = fileOutput.startsWith('Mach-O ') && fileOutput.endsWith(arch === 'arm64' ? 'arm64' : 'x86_64');
console.log('[copyFilter]', src.replace(sourceContentsPath, ''), res);
return res;
}
});
}
}
还有问题
启动时出现无法加载 dylib 文件的问题,M1 打的包 Intel 不行,Intel 打的包 M1 不行。
首先通过 file 命令,判断 dylib 大概率没有问题。
然后比对准备用来合并的两个文件夹里的 .node 文件,发现 ref-napi
有些问题;
ref-napi
├── build
│ └── Release
│ └── binding.node
...
└── prebuilds
├── darwin-x64
│ ├── electron.napi.node
│ └── node.napi.node
...
arm64 的有 build/Release/binding.node
,x64 的却没有,而是用的 prebuilds 里的 .node 文件 prebuilds/darwin-x64/electron.napi.node
;
arm64 机器上合并之后都会优先加载前者,前者又是单 arm64 的,导致在 x64 平台上加载失败;
而 x64 机器上合并之后,由于 asar 文件头中不存在前者的索引,所以在 arm64 平台上找不到可以使用的二进制文件,导致加载失败。
最后分析下来,最好是在 x64 平台编译一个 build/Release/binding.node
,和 arm64 的合并成通用二进制文件,这样就都可以加载到了。
rimraf node_modules/ref-napi/prebuilds && electron-builder install-app-deps
其他问题
安装 arm64 版的 Node.js Node.js 从 15.3.x 开始支持 arm64,可以使用 nvm 可以安装 15.3.x 以上版本,或从官网下载安装包进行安装。
如何判断 M1 机型运行了 x64 的包
uname -a
M1 运行 arm64 包
Darwin qiyunjiang.local 20.6.0 Darwin Kernel Version 20.6.0: Mon Aug 30 06:12:21 PDT 2021; root:xnu-7195.141.6~3/RELEASE_ARM64_T8101 arm64
M1 运行 x64 包
Darwin qiyunjiang.local 20.6.0 Darwin Kernel Version 20.6.0: Mon Aug 30 06:12:21 PDT 2021; root:xnu-7195.141.6~3/RELEASE_ARM64_T8101 x86_64
总结
Electron 打包过程中还遇到过很多很多的问题,通过不断去 Google、去 Github 上搜 issue,看源码,冷静分析和耐心尝试之后,一定可以找到那个最适合的答案。