说明
本文档介绍如何把 apk 预置进 Android14 系统镜像,让应用随镜像出厂就存在——烧录后开机就能在桌面上看到,不需要联网下载,也不是「开机后再安装一次」。整个流程不需要我们手写 Android.mk,只要把 apk 放进设备目录下的预置目录,编译时会自动搞定。
环境说明
- 板卡:
泰山派 3M-RK3576 - 系统:
Android14 - 产品(lunch 目标):
tspi_3m-userdebug - 设备目录:
device/rockchip/rk3576/tspi_3m
一句话原理
SDK 内置脚本 device/rockchip/common/auto_generator.py 会扫描预置目录里的 apk,自动生成编译模块、自动加进 PRODUCT_PACKAGES,最终打包进 odm 分区。开机时 PackageManagerService(下面简称 PMS)再扫描这些目录,把 apk 装进系统。
注意:apk 必须放在 tspi_3m 的设备目录下
预置目录要放在当前 lunch 产品对应的设备目录里,也就是 BoardConfig.mk 所在的那个目录。放到别的产品目录下不会报错,也不会生效——编译照样通过,但 apk 根本没进镜像。这个坑很隐蔽,排查问题时先回头确认目录放对没有。
三种预置目录的区别
tspi_3m 的设备目录下可以有三个预置目录,区别只在于用户能不能卸载,以及恢复出厂设置后会不会装回来:
| 项目 | preinstall/ | preinstall_del/ | preinstall_del_forever/ |
|---|---|---|---|
| 语义 | 预置不可卸载 | 预置可卸载(恢复出厂设置可恢复) | 预置可卸载(恢复出厂设置不可恢复) |
| 镜像内落位 | /odm/bundled_persist-app/ | /odm/bundled_uninstall_back-app/ | /odm/bundled_uninstall_gone-app/ |
| 是否系统应用 | 是 | 否 | 否 |
| 用户能否卸载 | 否 | 能 | 能 |
| 重启后是否自动装回 | — | 否 | 否 |
| 恢复出厂设置后 | — | 装回 | 设计上不装回 |
| 典型场景 | 核心业务、必备应用 | 可选应用,允许用户删掉 | 试用推广类,不希望被「重置」带回 |
三个目录的内部结构完全一样,每个目录里固定包含三个文件:
preinstall/
├── Android.mk # 内容固定:include $(call all-subdir-makefiles)
├── preinstall.mk # 空文件,构建时被脚本自动改写为 PRODUCT_PACKAGES += ...
└── TestApp.apk # ← 要放入的 apk2
3
4
不管放在哪个目录,apk 都是真正预置进镜像的,随系统出厂存在,与普通预置唯一的差别就是「可卸载性」由 PMS 的 prebundled 机制控制。想搞清楚这层机制,看 附录B 运行期发生了什么。
该选哪个?
拿不准时按这个顺序判断:
- 应用删了会影响板子正常使用 → 放
preinstall/ - 用户有权不用,恢复出厂设置应该还原 → 放
preinstall_del/ - 只是随机器附赠的推广应用,用户删掉就该永久消失 → 放
preinstall_del_forever/
预置流程概览
先整体过一遍要做的事,心里有个数:
准备 apk
│
▼
放进 device/rockchip/rk3576/tspi_3m/preinstall*/ 目录
│
▼ 编译时(每次编译都会重新扫描一次)
auto_generator.py 扫描目录
├── 为每个 apk 生成 <AppName>/Android.mk
└── 把模块名写进 preinstall.mk
│
▼
apk 被打包进 odm 分区:/odm/bundled_*-app/
│
▼ 烧录后首次开机
PMS 扫描 /odm/bundled_* → 解析安装 → 应用出现在桌面2
3
4
5
6
7
8
9
10
11
12
13
14
15
我们要动手做的只有前两步,剩下都是编译和开机自动完成的。
操作步骤
创建预置目录
tspi_3m 的设备目录里默认没有预置目录,需要我们自己建。先进入 SDK 根目录,再执行:
cd TaishanPi-3-Android14
for d in preinstall preinstall_del preinstall_del_forever; do
mkdir -p "device/rockchip/rk3576/tspi_3m/$d"
printf 'include $(call all-subdir-makefiles)\n' > "device/rockchip/rk3576/tspi_3m/$d/Android.mk"
: > "device/rockchip/rk3576/tspi_3m/$d/preinstall.mk"
done2
3
4
5
6
7
两个文件缺一不可
Android.mk 和 preinstall.mk 必须有。缺了的话 auto_generator.py 会抛异常,而它只把错误打到 stderr,不会中断编译——最后表现就是「编译过了但 apk 没进镜像」。
只用得上其中一两个目录的话,单独建也可以,不需要三个都建。
放入 apk
把 apk 复制进对应的预置目录,比如让测试应用可以被用户卸载、恢复出厂设置后能回来:
cp /path/to/TestApp.apk device/rockchip/rk3576/tspi_3m/preinstall_del/命名要求:
- 不要用中文、空格或特殊字符。apk 文件名会直接作为编译模块名,建议只用
[A-Za-z0-9_],例如TestApp.apk - apk 使用原始签名。脚本按
PRESIGNED处理,不做二次签名,所以预置的应用拿不到平台签名权限 - 首次编译后,apk 会被脚本移动到同名子目录(变成
preinstall_del/TestApp/TestApp.apk),这是正常现象,这个子目录需要一并纳入版本管理
编译镜像
依次执行:
0⃣. 设置环境变量:
source build/envsetup.sh1⃣. 选择产品配置:
lunch tspi_3m-userdebug2⃣. 开始编译:
./build.sh -UKAup -J$(nproc)编译细节
build.sh 的参数含义、以及不编译整个 SDK 只更新单个模块的做法,参考 Android14镜像编译🚀 章节。
编译完成后按 成品镜像烧录🚀 章节的方法烧录镜像即可。
验证
编译产物检查
烧录之前先确认 apk 进了产物:
ls -R out/target/product/tspi_3m/odm/bundled_uninstall_back-app/期望能看到 TestApp.apk(或者 TestApp/TestApp.apk)。目录空的说明 apk 没进镜像,回头检查 放入 apk 那一步的目录有没有放对。
板上检查
烧录后等首次开机完成,用 ADB 或串口登录板子:
# 预置文件在不在
adb shell ls -l /odm/bundled_uninstall_back-app/
# 应用有没有被系统装上
adb shell pm list packages | grep -i testapp
# 安装位置是不是指向 /odm
adb shell pm path com.example.testapp
# prebundled 名单内容
adb shell cat /data/system/prebundled-packages.list2
3
4
5
6
7
8
9
10
11
pm path 的输出里 codePath 应该指向 /odm/...,说明走的是预置流程而不是后来安装的。
卸载与恢复验证
# 卸载,等价于在设置里卸载该应用
adb shell pm uninstall com.example.testapp
# 重启后不应该再自动出现
adb reboot2
3
4
5
恢复出厂设置(清 /data)之后:preinstall_del/ 的应用会装回来,preinstall_del_forever/ 的设计预期是不装回。后者的实际表现有前提条件,见 常见问题。
更新与删除预置应用
| 操作 | 做法 |
|---|---|
| 替换版本 | 新 apk 放到预置目录顶层(与已生成的子目录同名),或先删掉旧子目录再放,然后重新编译 |
| 删除某个预置应用 | 删掉预置目录下对应的 <AppName>/ 整个子目录,重新编译(脚本会同步改写 preinstall.mk) |
| 临时禁用(保留文件) | 在该目录的 preinstall.mk 里把对应模块名注释掉,或者只清 out/ 下的产物 |
不要留下空目录
脚本是按「目录下现存的子目录名」重写 preinstall.mk 的。如果只删了子目录里的 apk、把空目录留在原地,编译会报 PRODUCT_PACKAGES += <模块> 找不到模块。删干净子目录,或者改成手工维护 preinstall.mk。
常见问题
编译之后 odm 目录里没有我的 apk
按顺序排查:
- apk 是不是放在
device/rockchip/rk3576/tspi_3m/preinstall*/下?放到别的产品目录不会报错,但也不生效 - 预置目录里
Android.mk和preinstall.mk都在吗? - 打开该目录的
preinstall.mk,看看有没有生成PRODUCT_PACKAGES += TestApp这一行 - 产物没刷新的话,先
make installclean,或者删掉out/target/product/tspi_3m/odm/下对应内容再编译
编译报错 PRODUCT_PACKAGES += xxx 找不到模块
预置目录下留了空的子目录。脚本按现存子目录名重写 preinstall.mk,但空目录里没有 apk,也就没有对应的模块。删掉空子目录重新编译即可。
烧录后应用没有自动出现
首次开机还没走完。PMS 的扫描发生在 systemReady 之前,属于开机关键路径,apk 越多首次开机越慢。多等一会儿再看。
如果等了很久还是没有:
adb shell cat /data/system/prebundled-packages.list包里记录的包名说明它曾经被扫描安装过。如果这次开机前应用被卸载过,这个包名会留在名单里,系统就跳过不再安装了——这是设计行为,不是故障。想让它重新出现,清 /data 恢复出厂设置。
预置后首次开机变慢了
这是正常的。不管用哪种预置方式,apk 都要在首次开机被 PMS 解析、dexopt 或校验、建立权限与用户记录,这是主要开销,和放在 /system 还是 /odm 没关系。
preinstall_del* 还会对每个 apk 多解析一次(用来取包名打标记),所以比 preinstall/ 的开销略大一点。
想压缩首次开机时间,有三个方向:
- 做编译期 dexpreopt。SDK 里脚本生成的模块固定带
LOCAL_DEX_PREOPT := false,也就是说镜像里不会预生成 oat/vdex,全部留到首次开机处理 - 减少预置 apk 的数量和体积,尤其是 dex 很多的 apk
- 真正「延迟到开机后再安装」——SDK 没有现成机制,需要自己用特权进程调
pm install,会带来权限、SELinux、安装时机和失败重试等额外复杂度
恢复出厂设置后应用还是装回来了
preinstall_del_forever/ 的「永久」依赖一份放在 /cache/recovery/last_deleteApkFile.dat 的删除名单,而标准 recovery 的恢复出厂流程(--wipe_data)会格式化 /data 和 /cache。/cache 被清掉,名单就失效了,apk 会被重新装回。
当前 SDK 代码里没有发现 recovery 侧对该文件做保留处理。如果业务上要求「绝对不恢复」,需要自己在 recovery 或开机流程里补保护逻辑。
附录A 编译期发生了什么
预置机制由产品配置继承引入,所有 RK 产品默认生效:
# device/rockchip/common/device.mk:62
$(call inherit-product, device/rockchip/common/modules/preinstall.mk)2
# device/rockchip/common/modules/preinstall.mk
ifneq ($(strip $(TARGET_PRODUCT)), )
$(shell python device/rockchip/common/auto_generator.py $(TARGET_DEVICE_DIR) preinstall bundled_persist-app $(TARGET_ARCH))
$(shell python device/rockchip/common/auto_generator.py $(TARGET_DEVICE_DIR) preinstall_del bundled_uninstall_back-app $(TARGET_ARCH))
$(shell python device/rockchip/common/auto_generator.py $(TARGET_DEVICE_DIR) preinstall_del_forever bundled_uninstall_gone-app $(TARGET_ARCH))
-include $(TARGET_DEVICE_DIR)/preinstall/preinstall.mk
-include $(TARGET_DEVICE_DIR)/preinstall_del/preinstall.mk
-include $(TARGET_DEVICE_DIR)/preinstall_del_forever/preinstall.mk
endif2
3
4
5
6
7
8
9
几个要点:
- 三个
$(shell python ...)在 product 配置解析阶段执行,也就是每次编译都会重新扫描一遍预置目录 TARGET_DEVICE_DIR由引导配置决定,等于BoardConfig.mk所在目录(build/make/core/board_config.mk:238)。tspi_3m产品对应device/rockchip/rk3576/tspi_3m-include前面带短横线,文件不存在也不报错;脚本遇到不存在的目录也直接跳过。这两点叠加,就解释了为什么 apk 放错目录后毫无提示
auto_generator.py 做了什么
脚本只扫描预置目录第一层的 *.apk,对每个 apk 执行:
建立同名子目录
<AppName>/,并把 apk 移动进去(<AppName>/<AppName>.apk)。源码树里的 apk 会被脚本挪位置,属于正常行为在子目录生成
Android.mk(BUILD_PREBUILT模块),关键配置:LOCAL_CERTIFICATE := PRESIGNED:保留 apk 原签名,不重新签名LOCAL_DEX_PREOPT := false:不做编译期 dex2oat,留给开机时 PMS 处理LOCAL_MODULE_PATH := $(TARGET_OUT_ODM)/bundled_xxx-app:决定落在 odm 分区的哪个目录
apk 内嵌 JNI lib 时,从 apk 中解出
lib/arm64-v8a或lib/armeabi-v7a,落到子目录lib/arm64或lib/arm,并在LOCAL_POST_INSTALL_CMD里追加「把 apk 复制到<AppName>/子目录 + 复制 so」的逻辑把该目录下所有子目录名(即模块名)重写进
preinstall.mk:makePRODUCT_PACKAGES += TestApp PRODUCT_PACKAGES += AnotherApp1
2
前置条件缺一不可,否则脚本抛异常且只打印到 stderr、不中断编译:预置目录必须存在,且目录下必须有 Android.mk 和 preinstall.mk。
产物落位
编译产物位于 out/target/product/tspi_3m/odm/bundled_xxx-app/ 下:
| apk 形态 | odm 内路径 |
|---|---|
| 不带 JNI lib | /odm/bundled_xxx-app/<AppName>.apk |
| 带 JNI lib | /odm/bundled_xxx-app/<AppName>.apk、/odm/bundled_xxx-app/<AppName>/<AppName>.apk(脚本额外复制的副本)、<AppName>/lib/arm*/*.so |
odm在 RK3576 上是独立分区,默认参与 super 动态分区(device/rockchip/common/build/rockchip/DynamicPartitions.mk:15,37),最终以odm.img形式产出或并入super.img- SELinux 已经有现成规则,apk 放进去就会被正确打标(
device/rockchip/common/sepolicy/vendor/file_contexts:245):
/(odm|vendor/odm)/bundled_(.*)(/.*)? u:object_r:vendor_app_file:s0反过来讲,我们也可以绕过脚本,直接把 apk 拷到 /odm/bundled_* 下——参考 附录C 其它预置方式。
附录B 运行期发生了什么
开机扫描流程
PMS 在系统启动早期构造时扫描系统应用,RK 在此之上增加了预置目录扫描:
PackageManagerService 初始化
└── InitAppsHelper.initSystemApps() (InitAppsHelper.java:170)
├── scanSystemDirs() # 常规 /system /vendor /odm 等
└── preinstallThirdPartyAPK() (InitAppsHelper.java:179, 207)
├── preinstallPrebundledpersist() → /odm/bundled_persist-app
├── preinstallPrebundledUninstallBack() → /odm/bundled_uninstall_back-app
└── preinstallPrebundledUninstallGone() → /odm/bundled_uninstall_gone-app2
3
4
5
6
7
扫描发生在 systemReady 之前,属于开机关键路径,所以会直接反映在首次开机耗时上。
「可卸载」是怎么实现的
三个预置目录在扫描时打的标记不一样,这是后面所有行为差异的源头:
| 预置目录 | 扫描 ParseFlag | 扫描 ScanFlag |
|---|---|---|
preinstall/ | PARSE_IS_SYSTEM_DIR | PARSE_IS_PREINSTALL | SCAN_AS_PREINSTALL | SCAN_AS_SYSTEM |
preinstall_del/ | PARSE_IS_PREBUNDLED_DIR | SCAN_AS_PREBUNDLED_DIR |
preinstall_del_forever/ | PARSE_IS_PREBUNDLED_DIR | SCAN_AS_PREBUNDLED_DIR |
preinstall/ 打的是 SYSTEM 标记,所以它是不可卸载的系统应用;后两个只打 prebundled 标记,因此可以卸载。判定逻辑在 InstallPackageHelper.scanSystemPackageLI(InstallPackageHelper.java:4192):
if ((parseFlags & PARSE_IS_PREBUNDLED_DIR) != 0) {
if (wasPrebundledPackageInstalledLPr(name) && existingSettings == null) {
// 曾经装过,现在没了 → 判定为用户主动卸载 → 不再装回
throw new PackageManagerException(INSTALL_FAILED_UNINSTALLED_PREBUNDLE, ...);
} else if (existingSettings != null && existingSettings.getVersionCode() >= ...) {
// 已从其它位置装过且版本不低于预置版本 → 跳过
}
}2
3
4
5
6
7
8
状态记录文件是 /data/system/prebundled-packages.list(Settings.java:736),记录曾经成功扫描安装过的 prebundled 包名:
- 扫描成功 →
markPrebundledPackageInstalledLPr()写内存 → 扫描结束后writePrebundledPackagesLPr()落盘 - 卸载后 apk 仍在 odm 分区(只读),但
packages.xml里已经没有这个包,而包名还留在 list 中 → 下次开机跳过安装。这就是「可卸载」的实现方式
另外两点:
- 每个 prebundled 包扫描成功后会额外解析一次 apk(
InstallPackageHelper.java:3798的new PackageParser().parsePackage(...),用于取包名打标记),因此preinstall_del*的首次开机开销略大于普通预置 - 新建用户时,prebundled 包会默认对所有用户安装(
Settings.java:4453-4459)
preinstall_del_forever 与恢复出厂设置
preinstall_del_forever/ 额外使用一份「永久删除名单」:
卸载 gone 类包
└── DeletePackageHelper.java:494-520
└── 包名追加写入 /cache/recovery/last_deleteApkFile.dat
开机扫描 /odm/bundled_uninstall_gone-app
└── InstallPackageHelper.java:3746-3775(readDeleteFile / isDeleteApk,PackageParser.java:650-690)
└── 包名在名单中 → 跳过该 apk,不安装2
3
4
5
6
7
行为对照:
| 场景 | preinstall_del/(back) | preinstall_del_forever/(gone) |
|---|---|---|
| 卸载后重启 | 不装回(list 记录) | 不装回(list + /cache 名单) |
恢复出厂设置(清 /data) | 装回(list 被清) | 设计上不装回(名单在 /cache 保留) |
附录C 其它预置方式
源码模块(树内 App)
不需要 apk 目录机制,直接在产品 mk 里引用模块名即可。本产品已有的范例:
# device/rockchip/rk3576/tspi_3m/tspi_3m.mk
PRODUCT_PACKAGES += Tspi3mPinTest2
源码模块走 AOSP 标准流程,属于「真·系统应用」,默认放系统分区、使用平台签名,具体取决于模块自身的配置。
显式拷贝 apk
适合「不想让脚本自动改写源码树」或者「需要精确控制路径」的场景:
# device/rockchip/rk3576/tspi_3m/tspi_3m.mk
PRODUCT_COPY_FILES += \
vendor/yourpath/TestApp.apk:$(TARGET_COPY_OUT_ODM)/bundled_uninstall_back-app/TestApp/TestApp.apk2
3
SELinux 规则(file_contexts:245)已经覆盖 /odm/bundled_*,PMS 同样会扫描并安装。缺点是不会做 apk 内 JNI lib 的解包和 lib 路径处理。
三种方式对比
| 方式 | 自动扫描 | 可卸载 | 需手写 mk | 说明 |
|---|---|---|---|---|
preinstall* 目录 | 是 | 取决于子目录 | 否 | 本文推荐,最省事 |
PRODUCT_PACKAGES + 自建 Android.mk | 否 | 否 | 是 | 完全可控,适合复杂场景 |
PRODUCT_COPY_FILES 到 /odm/bundled_* | 否 | 取决于目标目录 | 是(一行) | 简单直接,无 lib 处理 |
附录D 源码位置索引
| 作用 | 位置 |
|---|---|
| 预置机制继承入口 | device/rockchip/common/device.mk:62 |
| 目录 → bundled 目录名映射、调用扫描脚本 | device/rockchip/common/modules/preinstall.mk:19-24 |
| 自动生成模块脚本 | device/rockchip/common/auto_generator.py |
TARGET_DEVICE_DIR 定义 | build/make/core/board_config.mk:238 |
| odm 独立分区 / 动态分区配置 | device/rockchip/common/build/rockchip/DynamicPartitions.mk:15,37 |
/odm/bundled_* SELinux 标签 | device/rockchip/common/sepolicy/vendor/file_contexts:245 |
| 目录常量定义 | frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java:583-587 |
| 开机扫描入口 | .../server/pm/InitAppsHelper.java:179, 207-233 |
| prebundled 跳过重装判定 | .../server/pm/InstallPackageHelper.java:4192-4218 |
| prebundled 额外解析与名单写入 | .../server/pm/InstallPackageHelper.java:3740-3750, 3795-3805 |
| gone 类卸载写入删除名单 | .../server/pm/DeletePackageHelper.java:494-520 |
| prebundled 名单读写 | .../server/pm/Settings.java:736, 2746-2797 |
| 删除名单读取与匹配 | frameworks/base/core/java/android/content/pm/PackageParser.java:324, 650-690 |
| prebundled 目录 API | frameworks/base/core/java/android/os/Environment.java:117-119 |
| ParseFlag 定义 | frameworks/base/core/java/android/content/pm/PackageParser.java:972;.../pkg/parsing/ParsingPackageUtils.java:243 |