茶器艺科智造HarmonyOS应用实战-24-导出的数组和默认草稿都可被消费者改写:用readonly与工厂函数守住HAR边界
HAR 把SHOP_FILTER_KEYS、预设标签、快捷指令、雷达参考值和DEFAULT_CHAQI_DRAFT_STATE统一导出后,消费者拿到的不只是“常量名字”,还是数组与对象的引用。const只禁止重新给变量赋值,并不禁止reverse()、push()、下标赋值或修改对象属性。一个页面若为方便排序而原地 reverse,另一个页面看到的顺序也会改变;一个测试若向默认草稿的 orders push 数据,所有直接持有或再次复用这份公共默认对象的消费者都会看到测试订单。当前load()自己构造新外层状态,orders 也由parseOrders(..., '')生成,因此这项 orders 风险不会沿现有恢复路径自动传播。
逐项检查指定源码后,没有发现 entry 或 libraryhsp 当前调用这些常量的 push、pop、splice、sort、reverse、fill 或属性赋值;现有消费者以读取为主。这说明本文讨论的是 HAR API 可变性风险,不是宣称线上已经发生污染。风险仍值得在公共边界收口,因为libraryhar/Index.ets把这些引用直接交给所有消费者,模块内部无法约束未来代码或新的依赖方。
本文会区分三种保护:
- 原始值数组用
readonly表达只读借用。 - 对象数组同时把元素字段和嵌套成员设为只读。
- 需要独立所有权的默认草稿用工厂函数创建新对象与新数组。
- 第三方 API 必须接收可变数组时,调用方拿副本,不放宽 HAR 源数据。
一、源码锚点显示多个可变引用直接穿过 Index.ets
libraryhar/src/main/ets/model/ChaqiModels.ets当前导出以下数组:
export const SHOP_FILTER_KEYS: ShopFilter[] = ['all', 'printing', 'pending', 'completed']; export const PRESET_LABELS: string[] = ['罗汉杯', '悟空杯', '方盏', '高足杯', '螺旋瓶', '竹节杯']; export const CUP_PRESET_KEYS: CupPresetKey[] = ['luohan', 'wukong', 'fang', 'generic', 'spiral', 'zhujie']; export const CHAT_QUICK_CHIPS: ChatQuickChip[] = [ /* 五个对象 */ ]; export const ARCHIVE_RADAR_LABELS: string[] = ['高度', '腰比', '波纹', '半径', '壁厚', '口径']; export const ARCHIVE_RADAR_OPTIMAL: number[] = [75, 65, 85, 70, 45, 72];ChaqiDraftStore.ets:91还导出一个完整默认对象,其中orders是数组。libraryhar/Index.ets:16与:20-29将它们再次导出。当前页面用 ForEach 读取筛选键、预设标签与快捷指令;雷达组件将两个数组赋给局部 const 后读取;草稿服务把默认对象赋给局部 defaults,数值和字符串字段把它用作 fallback,而 orders 通过parseOrders(this.readString(store, KEY_ORDERS_JSON, ''))单独生成新数组,不读取defaults.orders。
局部再写一个 const 不会复制对象。const labels = ARCHIVE_RADAR_LABELS仍然指向同一个数组。
二、const 保护变量绑定,不保护数组和对象内容
下面代码在普通 TypeScript/ArkTS 语义上表达的都是原地修改风险:
SHOP_FILTER_KEYS.reverse(); PRESET_LABELS[0] = '临时杯型'; CUP_PRESET_KEYS.push('luohan'); ARCHIVE_RADAR_OPTIMAL.fill(0); CHAT_QUICK_CHIPS[0].text = '被消费者替换的指令'; DEFAULT_CHAQI_DRAFT_STATE.heightMm = 999; DEFAULT_CHAQI_DRAFT_STATE.orders.push(buildUnexpectedOrder());编译器是否允许每一行还受项目 ArkTS 版本和导出类型影响,但当前声明明确使用可变T[]和可写接口字段,没有在类型层表达禁止修改。即使调用方没有恶意,常见的sort()、reverse()、fill()也会就地改变源数组。
污染后的影响跨越功能:筛选按钮顺序改变;标签与 key 的索引对齐破坏;快捷指令文本变化;雷达“最优”曲线被重写;默认草稿中实际作为 fallback 的数值或字符串字段改变。orders 的共享修改只影响直接持有或复用公共默认对象的消费者,当前load()的 orders 路径已经通过解析创建新数组。它们看似是不同 bug,根因却是公共 API 仍把共享可变所有权交给了消费者。
三、原始值数组先改成只读借用
字符串、数字和联合类型都是原始值,数组只读即可阻止常见原地修改。建议把声明改为readonly T[]:
export const SHOP_FILTER_KEYS: readonly ShopFilter[] = ['all', 'printing', 'pending', 'completed']; export const ARCHIVE_RADAR_LABELS: readonly string[] = ['高度', '腰比', '波纹', '半径', '壁厚', '口径']; export const ARCHIVE_RADAR_OPTIMAL: readonly number[] = [75, 65, 85, 70, 45, 72];readonly的价值首先是编译期契约:消费者看到后就知道只能遍历和读取,不能调用要求可变数组的方法。它不等于运行时深冻结,也不能阻止绕过类型系统的 JavaScript 或强制断言。HAR 边界应以类型防误用,再用工厂或副本处理真正的所有权隔离。
如果采用第 23 篇的CUP_PRESETS配置表,标签和 key 不必再作为两组公共数组导出;确需兼容旧 API,可以由只读表派生只读快照,并标记迁移计划。
四、对象数组必须连元素字段与嵌套值一起约束
把CHAT_QUICK_CHIPS仅改为readonly ChatQuickChip[],只能阻止数组增删和重排,仍可执行CHAT_QUICK_CHIPS[0].text = ...,因为元素对象的字段还是可写。应给元素接口增加 readonly:
export interface ChatQuickChip { readonly title: string; readonly text: string; readonly hint: string; } export const CHAT_QUICK_CHIPS: readonly ChatQuickChip[] = [ { title: '① 罗汉·兔毫·略矮', text: '罗汉杯兔毫釉略矮', hint: '杯型词 + 兔毫 + 略矮/矮 → 范式、波纹、总高' } ];预设配置还有嵌套 defaults 与 aliases,需要显式深一层:
export interface ReadonlyCupDefaults { readonly heightMm: number; readonly diameterMm: number; readonly mouthMm: number; readonly bottomMm: number; readonly waistPct: number; } export interface CupPresetSpec { readonly key: CupPresetKey; readonly label: string; readonly profileKind: CupProfileKind; readonly defaults: ReadonlyCupDefaults; readonly aliases: readonly string[]; }“外层 readonly 数组 + 元素 readonly 字段 + 嵌套对象 readonly 字段”才表达了这份配置的只读借用。若以后 defaults 增加数组或对象,还要继续扩展只读类型;语言里的浅只读不会自动递归。
五、默认草稿需要工厂,因为调用方本来就要修改它
默认配置与草稿状态的所有权不同。当前load()已经返回新外层对象,并为 orders 创建新数组;但数值和字符串 fallback 仍从共享的DEFAULT_CHAQI_DRAFT_STATE读取,其他消费者也可以直接拿到这份公共对象。页面拿到草稿后会改变高度、页签、订单等字段,因此不能把整份状态永久声明成只读单例再到处强制转换。更直接的做法是取消公共默认对象,导出工厂:
export function createDefaultChaqiDraftState(): ChaqiDraftState { return { selectedTabIndex: 0, presetIndex: 0, heightMm: 70, diameterMm: 85, mouthMm: 85, bottomMm: 41, thicknessMm: 3, waistPct: 45, waveLevel: 0, autoSpin: true, shopFilter: 'all', sliceLayer: 0, sliceTotal: 160, sliceShowPaths: false, sliceInfillPct: 20, sliceNozzleTempC: 205, printerNameInput: 'Creality K1', printerIpInput: '192.168.1.100', printerPortInput: '8080', orders: [] }; }每次调用都会创建新外层对象和新 orders 数组。两个工厂实例之间不会共享订单或其他可写字段;现有load()的 orders 本来就没有读取defaults.orders,工厂主要把这种独立所有权扩展到所有 fallback 字段和其他消费者。测试也可以直接比较两个实例,而无需在 afterEach 手动恢复共享常量。
当前buildDemoOrders()已采用类似所有权思路:函数内部创建const list: ShopOrder[] = [],逐项填充后返回,每次调用获得新数组。computeArchiveRadarCurrent()也返回新 number[]。这两处可以作为项目内现有正例。
六、展开运算符只是浅拷贝,orders 仍要逐项复制
若从已存在的草稿复制可编辑状态,{ ...source }只创建新外层对象,orders仍指向原数组。即使 orders 数组被复制,订单对象也可能继续共享。当前 ShopOrder 字段都是原始值,可用显式 map 做完整的一层复制:
function copyOrder(order: ShopOrder): ShopOrder { return { id: order.id, status: order.status, progress: order.progress, name: order.name, material: order.material, date: order.date }; } export function copyDraftState(source: ChaqiDraftState): ChaqiDraftState { return { ...source, orders: source.orders.map((item: ShopOrder) => copyOrder(item)) }; }如果 ArkTS 目标版本对展开运算或匿名对象有额外静态限制,应把所有字段显式列出并以实际编译为准。不要把JSON.parse(JSON.stringify(source))当作通用深拷贝:它会丢失非 JSON 类型、类原型和特殊数值,未来模型扩展后风险更大。
浅只读与深拷贝解决不同问题。只读类型表达“借来的配置不可修改”;复制表达“调用方获得自己的可变所有权”。默认草稿显然属于后者。
七、只读类型要沿调用链传播,必要时在边缘复制
把常量改成 readonly 后,某些消费函数若仍声明number[],编译器会认为它可能修改数组,从而拒绝传入。正确做法是把真正只读的参数也改成 readonly:
private drawPolygon( values: readonly number[], radius: number, centerX: number, centerY: number ): void { // 只按下标读取 values,不做排序或填充 }当前ArchiveRadarCanvas只是读取 labels 和 optimal,适合一路使用 readonly。ForEach 的回调也只读元素,不需要可变数组。
若第三方绘图库的签名确实要求number[],应在调用边缘创建副本:
const mutableOptimal: number[] = [...ARCHIVE_RADAR_OPTIMAL]; thirdPartyChart.setValues(mutableOptimal);这样即使第三方原地 sort 或 fill,也只影响临时副本。不要为了一个可变 API 把 HAR 源常量重新声明成可变。
八、兼容迁移需要避免一次破坏所有消费者
直接删除DEFAULT_CHAQI_DRAFT_STATE会让现有 import 失败。可以分两步迁移:先新增工厂并让 ChaqiDraftStore 内部用每次新建的 defaults 替代共享 fallback,同时保留现有 orders 解析路径;再更新 Index.ets 与消费者,最后移除旧导出。
async load(ctx: common.UIAbilityContext): Promise<ChaqiDraftState> { const store = await this.getStore(ctx); const defaults = createDefaultChaqiDraftState(); const draft: ChaqiDraftState = { // 每个字段从 store 读取,fallback 来自本次独立 defaults selectedTabIndex: this.readNumber( store, KEY_SELECTED_TAB_INDEX, defaults.selectedTabIndex), // ...其余字段 orders: parseOrders(this.readString(store, KEY_ORDERS_JSON, '')) }; return draft; }save 中的 fallback 也应在一次调用开始时创建一份 defaults,避免反复调用工厂并保持本次保存的一致基线。对于筛选键、雷达值和快捷指令,改 readonly 可能暴露消费者签名问题;逐个把只读意图向下传播,只有确实需要修改的一端才复制。
九、验证矩阵、故障排查与证据边界
工厂的独立所有权可以用运行单测证明:
it('creates independent draft and order array', 0, () => { const first = createDefaultChaqiDraftState(); const second = createDefaultChaqiDraftState(); first.heightMm = 88; first.orders.push(sampleOrder()); expect(second.heightMm).assertEqual(70); expect(second.orders.length).assertEqual(0); });| 验证面 | 操作 | 预期证据 |
|---|---|---|
| 编译期数组边界 | 尝试 reverse/fill/push | readonly 调用被拒绝 |
| 编译期对象边界 | 尝试修改 quick chip text | readonly 字段赋值被拒绝 |
| 工厂独立性 | 创建两份默认草稿并修改其一 | 另一份数值和 orders 不变 |
| 雷达读取 | readonly 数组传入 drawPolygon | 编译通过、只读签名一致 |
| 第三方可变 API | 传展开副本并让其排序 | 源数组顺序不变 |
| 草稿恢复 | 连续 load 使用独立 defaults,并单独检查 orders 解析 | fallback 字段不共享;orders 继续由每次解析创建 |
| 现象 | 首查 | 常见根因 | 修正 |
|---|---|---|---|
| 改 readonly 后调用编译失败 | 消费函数参数类型 | 下游仍声明可变 T[] | 真只读则改签名,否则传副本 |
| 两份草稿 orders 同时变化 | 工厂是否每次 new 数组 | 复用了共享数组 | 在工厂内创建[] |
| 外层不能赋值但 chip 文本能改 | 元素接口字段 | 只读只加在数组上 | 元素与嵌套字段也加 readonly |
| spread 后订单仍互相影响 | 是否只做浅拷贝 | 元素对象继续共享 | typed map 复制订单 |
| 测试改变默认高度后其他用例失败 | 是否仍导出单例对象 | 默认状态被共享 | 改用工厂且每例新建 |
| 为兼容图库放宽全部常量 | 可变需求位置 | 所有权边界放错 | 只在调用边缘复制 |
当前源码事实是:上述数组均以可变T[]导出,快捷指令字段可写,默认草稿是共享对象且含 orders 数组,Index.ets 再次公开这些引用;现有load()返回新外层状态,orders 由parseOrders创建而不读取defaults.orders;现有工程消费者中未发现原地修改调用。本文的 readonly 类型、嵌套只读接口、草稿工厂、typed copy 与迁移步骤都是建议,尚未修改D:\ProgramData\huawei\lesson\chaqi-app-gitee-master。本次没有运行 ArkTS 编译、单测、hvigorw assembleHap --no-daemon、模拟器、真机或 CSDN 发布;因此可以确认静态 API 风险与建议边界,不能声称编译门禁和运行隔离已在原项目生效。