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: true 让 readdirSync 直接返回 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 即可——原理和本文一致,只是工具更趁手了。
小结
回头看,这个问题本身毫不起眼:一次手动复制。但它具备"自动化好手"的全部特征:
- 高频——每次 App 构建都要做;
- 易忘——忘了没有构建报错,只有装到手机上才发现图标不对;
- 有平台差异——不同操作系统命令不同;
- 修法便宜——30 行代码 + scripts 里加一个
&&。
遇到这种"小事"时不妨顺手把它钉死在构建流程里。与其指望每个人记住每一步,不如让流程自己长出腿来。
浙公网安备 33010602011771号