Skip to content

苹果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

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
}

platformPackager->doPack

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,看源码,冷静分析和耐心尝试之后,一定可以找到那个最适合的答案。

THANKS

Waiting For Your Next Big Idea