一次构建两种包:Debug 夹具与 Release 正式包的“物理文件切换“隔离

📅 2026/7/24 4:01:19
一次构建两种包:Debug 夹具与 Release 正式包的“物理文件切换“隔离
做有网络/硬件依赖的 App迟早会遇到这个矛盾开发期没有真机麦克风、没有真实 API Key也要能演示完整流程。所以需要一套假数据我们叫 DevFixture / Fake 服务最好还能一键切换 8 种预置场景。上架时Release 包里一行 Fake 代码都不能有。不只是不会被执行而是物理上不存在——审核、安全扫描、以及你自己的良心都要求这一点。常见的两种开关式做法在鸿蒙 ArkTS 上都给不了这个保证。这篇讲我们最终落地的第三种方案物理文件切换composition seam。1. 为什么 BuildProfile 开关和动态 import 都不够方案 ABuildProfile 字段 if 分支不够build-profile.json5可以给 debug/release 注入不同的构建字段buildModeSet: [ { name: debug, buildOption: { arkOptions: { buildProfileFields: { ENABLE_DEV_FIXTURE: true } } } }, { name: release, buildOption: { arkOptions: { buildProfileFields: { ENABLE_DEV_FIXTURE: false } } } } ]然后代码里写import { createFakeAsr } from ./service/fake/SpeakLabFakeAsr; // ← 问题在这 if (BuildProfile.ENABLE_DEV_FIXTURE) { runtime createFakeAsr(); } else { runtime createRealAsr(); }逻辑上 Release 永远走 else 分支。但**import语句在文件顶部是静态依赖**ArkTS 编译会把所有被静态 import 的模块一并打进字节码。也就是说Release HAP 里依然躺着完整的 Fake 实现只是运行时不会走到。不会执行和不存在是两回事——前者要靠运行时永远不出错来保证后者是编译器保证的。方案 B动态 import也不够那把 import 换成动态的if (BuildProfile.ENABLE_DEV_FIXTURE) { const fake await import(./service/fake/SpeakLabFakeAsr); }方向对了但实测下来被动态 import 的模块依然会被打包进 HAP。动态 import 解决的是加载时机不是产物裁剪。指望 Tree Shaking 把分支消除掉再顺手砍掉模块这依赖编译器对BuildProfile.ENABLE_DEV_FIXTURE做常量折叠属于把安全边界押在工具链的优化行为上——优化策略哪天变了你的 Release 包里就悄悄多出一套假数据。我们要的是一个不依赖任何优化行为的保证。结论既然问题出在这个文件被 import 了那就让 Release 构建时那个 import 语句根本不存在。2. composition seam一份接口两份实现构建前换掉文件做法一句话把组装应用根运行时这件事抽成一个 seam 模块给它两份物理实现构建前用脚本把对应的那份拷贝成活动文件。entry/src/main/ets/common/composition/ ├── SpeakLabShellComposition.ets # 活动文件AppShell 唯一 import 的 ├── SpeakLabShellComposition.debug.ets # Debug 模板静态 import DevFixture └── SpeakLabShellComposition.release.ets # Release 模板只 import 生产实现AppShell 只 import 活动文件import { createSpeakLabShellRuntime, ensureSpeakLabShellCompositionReady, SPEAKLAB_SHELL_BANNER } from ../common/composition/SpeakLabShellComposition;Release 模板fail-closedFake 这个词根本不出现Release 模板组装的是全生产路径CoreSpeech ASR、ArkAgent AI 服务、系统权限 port、Preferences 设置、filesDir 历史// SpeakLabShellComposition.release.ets import { SpeakLabCoreSpeechAsrService } from ../asr/SpeakLabCoreSpeechAsrService; import { SpeakLabProductionAiService } from ../ai/SpeakLabProductionAiService; import { SpeakLabSystemMicrophonePermissionPort } from ../permission/SpeakLabSystemMicrophonePermissionPort; // …没有任何 Fake / DevFixture import export function createSpeakLabShellRuntime(): SpeakLabAppRuntime { const asr new SpeakLabCoreSpeechAsrService(); const aiService new SpeakLabProductionAiService(coordinator, credential, store); // …组装并返回生产运行时 }注意它的设计姿态是fail-closed缺什么就坏掉而不是缺什么就用假的顶上。比如 Ability context 还没就绪时权限 port 不是返回一个假装的 GRANTED而是一个永远报告 UNKNOWN 的实现class SpeakLabReleasePermissionUnavailablePort implements SpeakLabMicrophonePermissionPort { async check(): PromiseSpeakLabPermissionStatus { return SpeakLabPermissionStatus.UNKNOWN; } // request / openSettings 同样 UNKNOWN }UI 层拿到 UNKNOWN 走权限未就绪的引导路径而不是误以为自己有权限。Release 的每一个降级分支都必须显式地坏这是这套方案能和假数据彻底切割的前提。Debug 模板同一个接口静态装入全套夹具Debug 模板导出完全相同的 API 表面内部换成夹具工厂// SpeakLabShellComposition.debug.ets import { createSpeakLabDevFixtureRuntime, createSpeakLabDevFixtureRuntimeById, SPEAKLAB_DEV_FIXTURE_BANNER } from ./SpeakLabDevFixtureComposition; // 静态 import故意的 export function createSpeakLabShellRuntime(): SpeakLabAppRuntime { return createSpeakLabDevFixtureRuntime(); } export function createSpeakLabShellRuntimeById(id: string): SpeakLabAppRuntime | null { return createSpeakLabDevFixtureRuntimeById(id); }注释里写得很直白Static imports of DevFixture are intentional here。Debug 包就是要带全套假数据8 个FAKE_*场景、明显的originFAKE审计横幅静态 import 保证它确实在包里。两份模板实现同一组导出函数createSpeakLabShellRuntime/createSpeakLabShellRuntimeById/ensureSpeakLabShellCompositionReady/ banner 常量……签名严格对齐——对齐本身就是约束 seam 两侧的调用方无感知。3. 切换脚本拷贝 grep 自检切换由scripts/apply-shell-composition.sh完成逻辑就是一行cp但附带了自检TEMPLATE$COMP_DIR/SpeakLabShellComposition.${MODE}.ets cp $TEMPLATE $ACTIVE # Sanity: Release must not mention DevFixture; Debug must. if [ $MODE release ]; then if grep -q SpeakLabDevFixtureComposition\|service/fake $ACTIVE; then echo apply-shell-composition: FAILED — Release seam still references DevFixture/fake 2 exit 1 fi fi拷贝之后立刻 grep 验证Release 活动文件里出现DevFixture或service/fake字样就直接失败退出。这个检查看起来冗余文件刚刚是你自己拷的但它把Release seam 干净从约定变成了每次构建都强制执行的不变式——哪天有人往 release 模板里 import 了假数据调试脚本当场拦住。构建脚本build-entry-hap.sh把切换和构建串成原子操作顺序不可颠倒bash $SCRIPT_DIR/apply-shell-composition.sh $MODE # 先切 seam hvigorw clean --no-daemon # 再 clean hvigorw --mode module -p productdefault -p moduleentrydefault \ -p buildMode${MODE} assembleHap --no-daemon # 最后构建只设buildMode而不切 seam 是不完整的——buildMode 只改编译参数不改源码。这也是为什么团队纪律是构建 HAP 只能走build-entry-hap.sh不许手敲 hvigorw。4. 双 HAP 审计把零 Fake验证到字节码层面seam 干净只是源码层面的保证。最终交付前还有一个check-release-fixture-isolation.sh同时吃 Release 和 Debug 两个签名 HAP解包审计字节码Release HAP不得出现 DevFixture / Fake 相关符号Debug HAP必须出现反向验证防止两边都没了的假阳性——Debug 都没了说明审计规则本身失效。这一正一反的设计值得多说一句。只做Release 里没有的检查遇到检查脚本写错了比如搜的路径不对永远搜不到会一路绿灯。加上Debug 里必须有检查机制本身也被检查了。这和后面 B18 会讲的 mutation 测试是同一个思想验证手段自身也要被验证。5. 这套方案的代价与应对诚实地说物理切换不是没有成本工作区会被脚本改动。SpeakLabShellComposition.ets是生成物切到 debug 构建后git status会显示它被修改。应对活动文件也提交与 release 模板保持一致的版本任何时候仓库里的默认状态都是 Release seam构建脚本负责临时换掉它。开发者改 Release 路径时同步编辑模板和活动文件两处文件头注释里写死了这个要求。两份模板要保持 API 对齐。Debug 加了新接缝函数Release 也得有对应签名哪怕是空实现或 fail-closed 实现。好在 seam 函数只有寥寥几个且 Hypium 测试会同时编译两份签名漂移编译期就炸。多了一步构建前仪式。用包装脚本收口后对开发者是透明的。换来的是任何时刻给审核、给客户、给安全同事的 Release 包都可以拍着胸脯说里面没有一行调试代码并且这句话有脚本、有审计、有双 HAP 证据支撑。6. 小结BuildProfile 分支和动态 import 都只控制执行不控制打包要 Release 零 Fake必须让 Fake在源码层面就不被 import。composition seam 一份接口、两份物理模板、构建前cp切换 grep 自检。Release 模板按 fail-closed 设计缺依赖就显式降级UNKNOWN绝不用假数据顶包。双 HAP 审计一正一反Release 必须没有Debug 必须有——检查机制本身也被检查。纪律buildMode不等于隔离构建只走build-entry-hap.sh。