一、审核机上的那次冷启动比崩溃更安静这次问题没有崩溃栈。测试人员把隐私协议从privacy-v5.1升到privacy-v5.2清掉进程后重新打开 ConsentLatch页面明明停在“协议已更新请重新确认”HiLog 却已经出现地图定位监听和统计会话。审核任务是AUDIT-1518复现时间 15:18。旧版本留下的同意记录仍是true启动代码只看这个布尔值于是两套三方 SDK 在弹窗出现前完成初始化。页面的合规提示是新的进程里的事实却还是旧的。最初的修补方案是在首页aboutToAppear里判断版本号。它能让演示通过却挡不住通知点击、深链和其他页面直接拉起 Ability也处理不了用户在设置页撤回授权后SDK 已注册的监听和定时器。最终我把它改成一条由 UIAbility 生命周期驱动的闸门先读取带版本的同意凭据再决定是否装配 SDK协议升级时先销毁旧实例用户重新同意后才重新创建。这不是把一个if写得更长而是把“用户同意”从页面状态改成可审计的进程状态。最终观测值固定为升级前实例 2 个升级时清理 2 个重新确认后只装配统计和地图 2 个模块推送模块按产品配置禁用 1 个重复初始化 0 次监听泄漏 0 个。最终状态为SDK_READY确认时间为15:18:24。二、先把布尔值拆成一张真正的凭据旧实现只存privacyAcceptedtrue。这个值无法回答用户同意的是哪一版文本、何时同意、页面显示的摘要是否与落盘内容一致。ConsentLatch 把凭据改成四个字段accepted、policyVersion、policyDigest和acceptedAt。当前目标版本固定为privacy-v5.2摘要显示为c9a7…52e1。只要版本或摘要不匹配即使布尔值仍为真也必须进入RECONSENT_REQUIRED。下面的ConsentRepository解决的是“读取速度”和“落盘可靠性”不能混为一谈的问题。Preferences 会缓存轻量数据get很快但用户按下同意按钮后必须等待flush()完成才能把状态推进到 SDK 装配。否则进程在两者之间被杀下一次启动会回到旧凭据而 SDK 本轮已经执行过一次。import{preferences}fromkit.ArkData;import{common}fromkit.AbilityKit;exportinterfaceConsentTicket{accepted:boolean;policyVersion:string;policyDigest:string;acceptedAt:number;}exportclassConsentRepository{privatestore?:preferences.Preferences;constructor(privatecontext:common.UIAbilityContext){}privateasyncdb():Promisepreferences.Preferences{if(!this.store){this.storeawaitpreferences.getPreferences(this.context,{name:consent_ticket});}returnthis.store;}asyncloadConsent():PromiseConsentTicket{constdbawaitthis.db();return{accepted:awaitdb.get(accepted,false)asboolean,policyVersion:awaitdb.get(policyVersion,)asstring,policyDigest:awaitdb.get(policyDigest,)asstring,acceptedAt:awaitdb.get(acceptedAt,0)asnumber};}asyncsaveConsent(version:string,digest:string,at:number):Promisevoid{constdbawaitthis.db();awaitdb.put(accepted,true);awaitdb.put(policyVersion,version);awaitdb.put(policyDigest,digest);awaitdb.put(acceptedAt,at);awaitdb.flush();}asyncclearConsent():Promisevoid{constdbawaitthis.db();awaitdb.clear();awaitdb.flush();}}这里没有把 Preferences 当数据库事务使用。四个put在内存里完成最后一次flush才定义“凭据已提交”的边界。项目边界也很明确它只保存同意事实不保存协议正文正文由版本化资源提供摘要用于校验对应关系。若正文体量、语言版本或审计要求更复杂应换成明确的数据模型而不是继续往键值表里堆字段。另一个易错点是多次调用getPreferences后反复订阅变化。本 Demo 把实例缓存到 Repository 生命周期内Ability 销毁时不额外挂永久监听因此没有重复订阅风险。若业务确实监听凭据变化必须保存回调引用并在退出时取消不能用匿名函数一订到底。三、SDK 注册表必须同时会“开”和“关”SDK 延迟初始化只解决了一半问题。协议升级、用户撤回授权或账号切换时已经存在的实例还会继续上报。如果注册表只提供startAll()所谓撤回只是把 UI 开关改成灰色。ConsentLatch 约定每个适配器都实现start()与stop()并由SdkRegistry维护当前活跃集合。下面这段代码解决三件事同一个模块不能重复启动清理按启动顺序的逆序执行禁用模块不会因为“全部启动”而被误装配。analytics和map允许启用push在本次任务中明确禁用所以最终统计是 initialized2、disabled1。interfaceSdkAdapter{id:string;enabled:boolean;start():Promisevoid;stop():Promisevoid;}exportclassSdkRegistry{privateactivenewMapstring,SdkAdapter();constructor(privatemodules:SdkAdapter[]){}asyncactivateApproved():Promise{initialized:number;disabled:number}{letinitialized0;letdisabled0;for(constsdkofthis.modules){if(!sdk.enabled){disabled;continue;}if(this.active.has(sdk.id)){continue;}awaitsdk.start();this.active.set(sdk.id,sdk);initialized;}return{initialized,disabled};}asyncrevokeAll():Promisenumber{conststartedArray.from(this.active.values()).reverse();letteardownCount0;for(constsdkofstarted){awaitsdk.stop();this.active.delete(sdk.id);teardownCount;}returnteardownCount;}getactiveCount():number{returnthis.active.size;}}逆序清理不是形式主义。地图适配器可能依赖统计会话记录耗时先关统计再关地图就会丢失最后一次退出事件真实项目应把依赖关系显式化复杂到一定程度则使用拓扑排序而不是依赖数组的“碰巧顺序”。stop()还要做到幂等注册表可能在协议升级、用户撤回和 Ability 销毁三个入口收到清理请求。重复调用不应再次注销已经注销的监听更不能抛错中断后续模块。四、用生命周期协调状态而不是让页面抢跑页面只是状态的观察者。真正的门禁放在ConsentCoordinator.syncPolicy()由EntryAbility.onForeground()触发。这样无论应用从桌面、通知还是深链进入都会先完成凭据核验。状态机只有五个关键值BOOT_LOCKED、RECONSENT_REQUIRED、SDK_BLOCKED、CONSENTED和SDK_READY。升级发现旧凭据时先进入RECONSENT_REQUIRED执行revokeAll()后进入SDK_BLOCKED只有保存新凭据成功才允许继续。下面代码中的syncToken用来丢弃重复前台回调产生的旧结果。某些设备切窗、解锁或系统弹窗返回会让前后台切换比预想频繁若每次都启动一条独立异步链较慢的旧读取可能覆盖较新的同意结果。constCURRENT_VERSIONprivacy-v5.2;constCURRENT_DIGESTc9a7…52e1;exportclassConsentCoordinator{state:stringBOOT_LOCKED;teardownCount:number0;privatesyncToken:number0;constructor(privaterepo:ConsentRepository,privateregistry:SdkRegistry){}asyncsyncPolicy():Promisevoid{consttokenthis.syncToken;constticketawaitthis.repo.loadConsent();if(token!this.syncToken)return;constmatchedticket.acceptedticket.policyVersionCURRENT_VERSIONticket.policyDigestCURRENT_DIGEST;if(!matched){this.stateRECONSENT_REQUIRED;this.teardownCountawaitthis.registry.revokeAll();this.stateSDK_BLOCKED;return;}this.stateCONSENTED;awaitthis.registry.activateApproved();if(tokenthis.syncToken)this.stateSDK_READY;}asyncconfirm(at:number):Promisevoid{awaitthis.repo.saveConsent(CURRENT_VERSION,CURRENT_DIGEST,at);awaitthis.syncPolicy();}asyncwithdraw():Promisevoid{this.syncToken;this.teardownCountawaitthis.registry.revokeAll();awaitthis.repo.clearConsent();this.stateSDK_BLOCKED;}}onBackground()不直接销毁 SDK因为后台切换不等同于撤回授权地图模块是否暂停定位应由其自身的前后台策略处理。真正释放发生在政策不匹配、用户撤回和 Ability 最终销毁三个语义入口。这样既不会每次切后台都反复初始化也不会把“资源暂停”和“授权撤销”混成一个动作。ConsentAuditPage 只订阅协调器快照确认按钮调用confirm(15:18:24)对应的时间戳撤回按钮调用withdraw()。按钮被连续点击时UI 在提交期间禁用即使事件重复抵达Preferences 的覆盖写和注册表的幂等集合也会把重复初始化压成 0。五、把日志变成一条能复盘的证据链目录中与这次排查直接相关的文件只有四个entryability/EntryAbility.ets负责前后台触发data/ConsentRepository.ets保存版本化凭据sdk/SdkRegistry.ets统一装配与销毁pages/ConsentAuditPage.ets展示状态。调试时我不用“是否弹窗”判断结果而是固定打印任务号、状态迁移和计数。关键日志按顺序应为AUDIT-1518 BOOT_LOCKED - RECONSENT_REQUIRED policyprivacy-v5.2revokeAll teardown2 active0用户确认后SDK_BLOCKED - CONSENTED at15:18:24最后activateApproved initialized2 disabled1 duplicateInit0与CONSENTED - SDK_READY listenerLeak0。任何 SDK 的网络日志出现在CONSENTED之前都直接判为门禁失败。这里还专门做了三组破坏性测试。第一组把旧版本的accepted留为真只改版本号预期仍然阻断第二组在flush()前强杀进程预期下次仍要求确认第三组连续触发两次前台和两次确认预期activeCount始终为 2duplicateInit 保持 0。测试不是为了把数字凑绿而是验证状态边界能承受真实生命周期的抖动。六、最终画面与仍需守住的边界任务AUDIT-1518的最终页面停在SDK_READY政策版本privacy-v5.2摘要c9a7…52e1确认时间15:18:24统计与地图两个模块为已装配推送为策略禁用升级清理 2、重新装配 2、重复初始化 0、监听泄漏 0。页面上的数字全部来自协调器快照不使用为了展示而硬编码的另一套变量。这个方案没有宣称替代隐私合规评审。它解决的是客户端可验证的工程问题何时允许三方代码启动、版本升级怎样让旧同意失效、撤回后怎样释放资源、审核时怎样拿到一致证据。三方 SDK 如果在静态初始化器、模块顶层或系统组件中提前执行注册表也拦不住这类依赖必须继续下沉到适配器内部并检查其初始化文档。还有两个边界不能省略。其一多进程或扩展 Ability 共用同意状态时需要考虑跨进程一致性不能默认单个 Preferences 实例覆盖全部场景。其二SDK 的stop()是否真的停止数据采集必须用抓包、线程和回调计数验证不能只相信方法名。ConsentLatch 的价值不在于多了一层封装而在于把审核中最难证明的“没有提前做”变成一条可观察、可回放、可失败的状态链。七、参考资料HarmonyOS UIAbility 生命周期参考通过用户首选项实现数据持久化