一、JSON 能解析,页面照样能被它拖垮
FlagHarbor 的首页灰度配置已经跑了几个月,事故却来自一次“合法 JSON”。任务FLAG-1534在 15:34 收到cfg_home_v12的第 73 代配置,网络请求 184 ms,正文 12,864 字节,ETag 是W/"9f2c-73"。JSON.parse没报错,ArkUI 的 Grid 随后拿到columns=0,预取调度又拿到prefetch=-3。前者让布局计算失去意义,后者把一段保护不足的循环推向异常。
更麻烦的是,用户快速切换网络后还有两条请求并行:较新的第 73 代先返回并失败,稍旧的请求随后抵达,旧代码却把它当成最新结果提交。于是我们看到的不是稳定回退,而是页面在默认值、旧值和坏值之间闪烁。最后我没有继续给每个 ArkUI 属性补Math.max,而是把远程配置当成不可信输入:先用 Zod 4 做运行时收窄,再用请求代次阻止过期结果提交,最后只从“已验证的最近好快照”回滚。
Demo 的最终状态刻意保留为FALLBACK_ACTIVE。第 73 代发现 2 个问题:columns=0、prefetch=-3;最近好快照仍是第 72 代,灰度比例 25%,过期响应丢弃 1 次。页面稳定使用home-feed-v4的第 72 代参数,不把解析成功误写成业务有效。
二、Schema 是 UI 边界,不是接口注释
服务端 TypeScript 类型不会穿过网络。即使客户端和服务端共用接口声明,线上响应也只是字节串;as HomeFeedConfig只会让编译器闭嘴,不会让columns=0自动变成 2。FlagHarbor 用 Zod 4 定义唯一运行时入口,并把类型从 Schema 推导出来,避免一份接口类型和一份校验规则渐渐分叉。
这一段解决的是字段存在但取值越界的问题。columns只能为 1 到 4 的整数,prefetch只能为 0 到 12,rollout是 0 到 100 的整数;版本标识必须是字面量home-feed-v4。.strict()让未知字段显式失败,避免服务端拼错字段名时客户端默默使用默认值,掩盖发布错误。
import{z}from'zod';exportconstHomeFeedSchema=z.object({schema:z.literal('home-feed-v4'),configId:z.string().min(1),generation:z.number().int().nonnegative(),rollout:z.number().int().min(0).max(100),layout:z.object({columns:z.number().int().min(1).max(4),cardGapVp:z.number().min(4).max(32)}).strict(),loading:z.object({prefetch:z.number().int().min(0).max(12),timeoutMs:z.number().int().min(300).max(5000)}).strict()}).strict();exporttypeHomeFeedConfig=z.infer<typeofHomeFeedSchema>;exportfunctionvalidateConfig(raw:unknown):HomeFeedConfig{constparsed=HomeFeedSchema.safeParse(raw);if(!parsed.success){constcompact=parsed.error.issues.map(issue=>`${issue.path.join('.')}=${issue.message}`);thrownewError(`CONFIG_INVALID:${compact.join('|')}`);}returnparsed.data;}选择safeParse()而不是直接parse(),是因为调用方需要把问题转成可展示、可统计的结构,而不是只接一个异常。Demo 的诊断层进一步把两个 issue 映射为layout.columns=0和loading.prefetch=-3,但不会把整份远程 JSON 写进日志,以免配置中未来加入敏感或高体量字段。
Schema 也不负责一切。业务上的“第 73 代必须大于第 72 代”属于提交规则,不该塞进字段校验;灰度命中依赖稳定设备桶,也不等于单字段范围。把所有约束都写进一个巨大 refine,会让错误路径难以测试,也让缓存恢复依赖网络上下文。我的边界是:Schema 只回答“这份对象能否安全进入应用层”,Store 再回答“它此刻是否应该生效”。
三、异步请求靠代次提交,不靠返回顺序
原实现用一个loading布尔值阻止重复请求,但网络切换、手动刷新和前台恢复仍可能同时发起。取消请求不是完整答案:底层回调可能已经排队,或者取消失败。FlagHarbor 每次刷新递增requestEpoch,提交前同时检查 epoch 和配置 generation。旧请求即使成功,也只能被计入staleDropped,不能修改 UI。
下面的RemoteConfigLoader串起下载、解析、校验和回退。fetchJson返回的元数据固定包括耗时、字节数和 ETag,便于把第 73 代问题与实际请求对应。失败时先从SnapshotStore读取最近好快照,再次通过同一 Schema 校验,避免“缓存天然可信”的错觉。
interfaceFetchResult{raw:unknown;latencyMs:number;bytes:number;etag:string;}exportclassRemoteConfigLoader{privaterequestEpoch:number=0;staleDropped:number=0;constructor(privatesnapshots:SnapshotStore){}asyncrefresh(fetchJson:()=>Promise<FetchResult>):Promise<LoadResult>{constepoch=++this.requestEpoch;constresponse=awaitfetchJson();if(epoch!==this.requestEpoch){this.staleDropped++;return{state:'STALE_DROPPED'};}constparsed=HomeFeedSchema.safeParse(response.raw);if(parsed.success){constlast=awaitthis.snapshots.read();if(last&&parsed.data.generation<last.generation){this.staleDropped++;return{state:'STALE_DROPPED'};}awaitthis.snapshots.write(parsed.data,response.etag);return{state:'REMOTE_APPLIED',config:parsed.data};}constfallback=awaitthis.snapshots.readValidated();return{state:'FALLBACK_ACTIVE',config:fallback,issues:parsed.error.issues,metrics:response};}}提交检查必须位于所有异步步骤之后。如果只在请求返回时检查 epoch,随后读取缓存或写盘期间又发起了新请求,旧链仍可能在末尾覆盖状态。生产版本在 Store 的commit()前再做一次令牌校验;本文为了让核心路径可读,保留了单写者约束:所有 refresh 都通过同一个 loader,SnapshotStore 不直接更新页面。
generation 的比较也不能简单写成“不相等就拒绝”。同一代配置可能因 CDN 重试重复送达,这是幂等命中;低于最近好代次才是回退响应,高于它才有资格进入校验和持久化。若服务端发生正式回滚,应发布更高 generation、内容回到旧参数,而不是把 generation 数字倒退。
四、最近好快照必须经过同一扇门
很多“回退”代码只是把上一份 JSON 原样落盘,再用类型断言读回来。应用升级后 Schema 已经从home-feed-v3变成home-feed-v4,旧缓存可能不再满足当前页面要求。FlagHarbor 的 SnapshotStore 保存config、etag和savedAt,读取时仍运行HomeFeedSchema.safeParse();无效缓存会被清除并返回内置安全默认值,而不是继续传播。
这一段解决持久化边界和并发覆盖。只有已经验证、且代次不小于当前快照的对象能写入。写入后flush()完成,Store 才能向 UI 发出REMOTE_APPLIED。如果进程在 flush 前终止,下一次启动最多回到第 72 代,不会出现 UI 显示第 73 代而磁盘仍是第 71 代的伪成功。
import{preferences}from'@kit.ArkData';import{common}from'@kit.AbilityKit';exportclassSnapshotStore{privatedb?:preferences.Preferences;constructor(privatecontext:common.UIAbilityContext){}privateasyncopen():Promise<preferences.Preferences>{if(!this.db){this.db=awaitpreferences.getPreferences(this.context,{name:'flag_harbor_snapshot'});}returnthis.db;}asyncreadValidated():Promise<HomeFeedConfig>{constdb=awaitthis.open();consttext=awaitdb.get('config','')asstring;if(!text)returnSAFE_HOME_FEED;try{constresult=HomeFeedSchema.safeParse(JSON.parse(text));if(result.success)returnresult.data;}catch(_){}awaitdb.delete('config');awaitdb.flush();returnSAFE_HOME_FEED;}asyncread():Promise<HomeFeedConfig|undefined>{constcfg=awaitthis.readValidated();returncfg.configId==='builtin-safe'?undefined:cfg;}asyncwrite(config:HomeFeedConfig,etag:string):Promise<void>{constdb=awaitthis.open();constcurrent=awaitthis.read();if(current&&config.generation<current.generation)return;awaitdb.put('config',JSON.stringify(config));awaitdb.put('etag',etag);awaitdb.put('savedAt',Date.now());awaitdb.flush();}}内置SAFE_HOME_FEED同样在单元测试里通过 Schema,而不是靠开发者目测。Preferences 适合这份十几 KB 的轻量快照;若配置增长到多份历史、图片二进制或频繁写入,应改用文件或关系型存储。另一个生命周期要求是不能在每次组件重建时创建 Store:ExperimentGuardPage 只持有观察状态,loader 与 store 由页面级 ViewModel 或 Ability 容器提供,页面退出时取消订阅,网络请求仍可完成但不会再投递到已销毁视图。
五、ArkUI 只消费验证后的不可变快照
ExperimentGuardPage 的 Grid 不再直接绑定原始 JSON,而是绑定ExperimentSnapshot:state、config、issues、staleDropped和请求指标一次性提交。这样columns与prefetch不会分两次更新,避免一帧里出现新列数配旧预取值。真正影响布局的值在提交时复制成新对象,旧快照仍可供日志和回退比较,页面不在原对象上就地修改。
工程目录也按边界拆开:schema/HomeFeedSchema.ets负责运行时收窄,network/RemoteConfigLoader.ets管理请求代次,data/SnapshotStore.ets持久化最近好配置,pages/ExperimentGuardPage.ets渲染诊断页。HiLog 的关键行是FLAG-1534 recv gen=73 bytes=12864 latency=184ms etag=W/"9f2c-73",随后是validate failed issues=2 columns=0 prefetch=-3,再到fallback gen=72 rollout=25 state=FALLBACK_ACTIVE和staleDropped=1。
我把调试按钮设计成“模拟坏配置”和“重新拉取”,而不是只放一个成功按钮。连续点三次重新拉取,前两条慢请求必须被 epoch 拦下;把快照中的 schema 手工改成 v3,启动时必须清除缓存并使用内置安全值;把第 73 代修正为 columns=2、prefetch=6 后,状态才允许从FALLBACK_ACTIVE进入REMOTE_APPLIED。这些用例覆盖的是边界顺序,不只是 Schema 的字段规则。
六、故障页面比 Toast 更有用
本次运行最终保留故障而不自动伪造成功。手机页面显示FALLBACK_ACTIVE,当前应用 generation 72、rollout 25%、schemahome-feed-v4;被拒绝的是 generation 73,错误数量 2,字段值明确为columns=0和prefetch=-3。请求数据保持 184 ms、12,864 B、ETagW/"9f2c-73",staleDropped 为 1。
这种诊断页不应在正式用户界面泄露全部配置,但开发构建、灰度内测和远程日志需要保留同一组结构化字段。普通用户只看到“已使用稳定配置”,开发人员则能用 configId、generation 和 ETag 定位服务端版本。两套文案共享同一个状态对象,避免用户页面说已恢复,日志却仍显示坏配置已生效。
还有几个边界值得写在交付单上。Zod 校验运行在主线程时,超大对象仍可能造成卡顿;当前 12,864 字节可接受,超过约定阈值应前移到任务池或减少载荷。safeParse的成功也不代表资源 URL 可访问、实验桶稳定或业务组合合理,这些要进入后续阶段。最后,ArkTS 工程接入三方 npm 包时应锁定兼容版本并验证构建产物,不要因文章写了 Zod 4 就无条件漂移到未来大版本。
FlagHarbor 的最终收益不是少了一次崩溃,而是把远程配置从“服务端说什么就渲染什么”改成四个明确关口:字节能否解码、对象能否收窄、响应是否仍是当前代次、失败时是否有可验证快照。只要这四个问题有一个答案是否定的,ArkUI 就不会看到那份对象。
七、参考资料
- Zod 官方:Basic usage 与 safeParse
- Zod 官方:Defining schemas
- Zod 4 官方发布说明
- HarmonyOS 用户首选项持久化指南