uni-app 构建 App 后图标、启动图丢了?用 30 行脚本一劳永逸

uni-app 构建 App 后图标、启动图丢了?用 30 行脚本一劳永逸

我们的 uni-app 项目用 CLI(uni build -p app-android)构建 App 端资源,再交给 HBuilderX 云打包。有段时间隔三差五有人打包出来发现:App 装到手机上图标是默认的"U",启动图也是白的。原因不是配置丢了,而是资源没有跟上构建产物走。本文分享我们用一个 30 行的 Node 后置脚本把这个手动动作自动化掉的过程。

问题现场

先说现象。在 HBuilderX 里通过"App图标配置"、"启动界面配置"可视化设置图标和启动图后,这些资源会被生成到项目根目录的 unpackage/res/ 下:

unpackage/
└── res/
    ├── icons/          # 20x20 ~ 1024x1024,各平台各尺寸
    │   ├── 1024x1024.png
    │   ├── 192x192.png
    │   └── ...
    └── splash/         # 启动图
        ├── 720_1242.png
        └── ...

走 HBuilderX 纯可视化流程时,打包会自己来这里取资源,一切正常。

但我们项目的 App 端不走这条路,构建命令是:

uni build -p app-android

构建产物输出到 dist/build/app/。此时 uni 会生成一个空的 dist/build/app/unpackage/res/ 目录结构——注意,只有骨架,里面的图标和启动图是空的。后续用 HBuilderX 对这个产物做云打包时,取不到图标和启动图,打出来的包就是默认图标 + 白屏启动页。

根目录的 unpackage/res/ 里明明躺着全套资源,uni 就是不会帮你搬过去。

土办法的痛

最初的解法很朴素:每次构建完,手动把根目录 unpackage/ 整个复制到 dist/build/app/ 下。

手动做的事,迟早会被忘掉。而且这个小动作还有跨平台的"赠品":

  • Windows 同事用 xcopy /E /I 或资源管理器拖拽;
  • Mac 同事用 cp -r
  • 轮到写 CI 脚本时还得再挑一遍命令。

一个连参数含义都不需要记的小活,散落在不同人的肌肉记忆里,这就是 bug 的温床。

方案:挂一个构建后置脚本

思路很简单:构建命令本身就在 package.json 的 scripts 里,用 && 在末尾追加一个 Node 脚本,让它替所有人做这个复制动作。

完整代码(scripts/copy-unpackage.js):

/**
 * App 构建后置脚本:把根目录 unpackage/(图标 icons、启动图 splash 等 App 资源)
 * 复制到 dist/build/app/unpackage/ 下。
 */
const fs = require("fs");
const path = require("path");

const root = path.resolve(__dirname, "..");
const src = path.join(root, "unpackage");
// uni-app app 构建产物固定输出到 dist/build/app
const dest = path.join(root, "dist/build/app/unpackage");

function copyDir(from, to) {
  if (!fs.existsSync(from)) {
    console.warn(`[copy-unpackage] 源目录不存在,跳过: ${from}`);
    return;
  }
  fs.mkdirSync(to, { recursive: true });
  for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
    const s = path.join(from, entry.name);
    const d = path.join(to, entry.name);
    if (entry.isDirectory()) {
      copyDir(s, d);
    } else {
      fs.copyFileSync(s, d);
    }
  }
}

copyDir(src, dest);
console.log(`[copy-unpackage] unpackage/ -> dist/build/app/unpackage 完成`);

然后在 package.json 里接入(以 Android 为例,其余 App 平台同理):

{
  "scripts": {
    "build:app-android": "uni build -p app-android --minify && node scripts/copy-unpackage.js"
  }
}

从此,任何人、任何平台、任何 CI 上跑构建,图标和启动图都会自动就位。

代码走读

整个脚本不到 30 行,但有几个值得说道的细节。

为什么用 Node,而不是 cp / copy

这是脚本注释里特意写明的一条:避免 Windows 与 Unix 拷贝命令差异。npm scripts 里的 shell 命令在不同平台由不同的 shell 执行(Windows 上 && 之后可能是 cmd),cp 在 cmd 里不存在,copy 语义又不完全一致。而 node scripts/xxx.js 在任何装了 Node 的机器上行为都完全一致——对这个小项目来说,用 Node 写文件操作是成本最低的跨平台方案。

这个原则可以推广:npm scripts 里凡是"能一条 shell 命令搞定、但那条命令有平台差异"的操作,都值得考虑换成几行 Node。

路径以 __dirname 锚定,与执行目录无关

const root = path.resolve(__dirname, "..");

__dirname(脚本文件自身所在目录)反推项目根目录,而不是依赖 process.cwd()。这样无论从项目根、从 scripts/ 里、还是从 CI 的任何工作目录调用,路径解析结果都一样。npm scripts 调用时 cwd 确实就是项目根,但这层保障是免费的,写上不吃亏。

递归复制的最小实现

fs.readdirSync(from, { withFileTypes: true })

withFileTypes: truereaddirSync 直接返回 Dirent 对象,带 isDirectory() 方法,省去对每个条目再调一次 fs.statSync 的开销,也是官方推荐的写法。目录递归、文件 copyFileSync,几行就是一个不带依赖的递归复制。

目标路径先 fs.mkdirSync(to, { recursive: true }),父目录不存在也会自动逐级创建。

源目录不存在:warn 跳过,而不是抛错

if (!fs.existsSync(from)) {
  console.warn(`[copy-unpackage] 源目录不存在,跳过: ${from}`);
  return;
}

这是一个刻意的取舍。如果某个项目/分支还没配置过图标,unpackage/res/ 压根不存在,这时候直接抛错会把整条构建命令判为失败,但构建本身其实是成功的。选择打 warn 跳过,让问题可见、但不阻断主流程——毕竟一个复制资源的小脚本,没资格让 build 挂掉。

接入位置的讲究:&&

uni build ... --minify && node scripts/copy-unpackage.js

&& 保证只有前面构建成功才执行复制。如果用 ;&,构建失败后脚本照样跑,会把上一次的旧资源复制进一个失败/不完整的产物目录,反而制造混乱。

顺便一提:新版 Node 可以一行搞定

如果你项目的 Node >= 16.7,其实有官方的递归复制 API:

const fs = require("fs");
fs.cpSync("unpackage", "dist/build/app/unpackage", { recursive: true });

整个 copyDir 函数可以浓缩成这一行(错误处理可以包在 try/catch 里按需补)。我们的脚本保持手写递归,一是写得早,二是不想引入对 Node 小版本的心理门槛。新写的话,直接用 fs.cpSync 即可——原理和本文一致,只是工具更趁手了。

小结

回头看,这个问题本身毫不起眼:一次手动复制。但它具备"自动化好手"的全部特征:

  1. 高频——每次 App 构建都要做;
  2. 易忘——忘了没有构建报错,只有装到手机上才发现图标不对;
  3. 有平台差异——不同操作系统命令不同;
  4. 修法便宜——30 行代码 + scripts 里加一个 &&

遇到这种"小事"时不妨顺手把它钉死在构建流程里。与其指望每个人记住每一步,不如让流程自己长出腿来。

posted on 2026-09-14 09:50  中文还在写码  阅读(7)  评论(0)    收藏  举报