混淆开启后 @Trace 不刷新、RelationalStore 报 no column:把「名字」从持久化契约里摘出去

一、前言

有一类鸿蒙线上问题,特征极其一致:Debug 包一切正常,Release 包一开混淆就崩,而且崩的地方风马牛不相及——数据库报 no column,UI 改了数据不刷新,日志里的类名叫 a1。开发者第一反应通常是「混淆规则漏了」,于是往 obfuscation-rules.txt 里加 -keep 目录,加完发现 UI 好了数据库还坏,再加一批 -keep-property-name,终于全绿了,但混淆收益几乎归零,包体积和反编译防护两头没占到。

这篇文章想说的是:这类问题的根因不是"keep 规则写得不够多",而是你把不稳定的东西写进了持久化契约。 数据库列名、状态观测点这些必须长期稳定的"名字",被绑在了会被混淆改写的 ArkTS 属性名上。只要这条隐式依赖还在,keep 名单就永远补不完;一旦断掉它,需要的白名单会少一个数量级。

文中所有混淆选项、版本号、默认值均取自官方文档《ArkGuard 混淆保留选项》《ArkGuard 混淆配置选项》《混淆加固》《构建 HAR》,非凭记忆。

二、问题描述

问题通常以三个互不相干的现象同时出现,这也是它难查的原因——症状分散在不同模块,容易让人以为是三个 Bug。

现象一:数据库读写报错。 Release 包首次建表后,insert 成功、query 抛异常,错误信息类似 no such column: a1;或者 query 不报错但所有字段读出来是 null。更隐蔽的版本是:本该 CREATE TABLE 的列名被写成了混淆后的短名,于是老用户升级后旧表和新代码对不上,触发一次意料之外的迁移。

现象二:@ObservedV2 / @Trace 失效。 数据结构变了、定时器也在改值,但界面纹丝不动;把同样的代码放回 Debug 包又完全正常。这类问题几乎从不报错,只是"不刷新",因此容易被误判成"数据没变"或"UI 没触发重绘"。

现象三:只在 release + 混淆开启时出现。 因为 obfuscation.ruleOptions.enable 默认为 false,且官方明确"release 编译支持混淆,而 debug 编译不支持混淆",所以日常 debug 调试全绿,CI 出的 Release 包才出问题——问题被推迟到了最贵的环节。

顺带纠正一个常见排查动作:官方在提示里专门写了,“若要明确应用行为差异是否由混淆引起,应通过开启或关闭混淆开关进行排查,而不是仅通过切换编译模式”。因为 Debug/Release 的差异不止混淆一项(还有压缩、-remove-log 等),只切构建模式会把混淆问题和其它构建差异混在一起。

一个典型的最小复现路径是这样的:

// model/AccountEntity.ets —— 看起来完全没问题的一段代码
import { relationalStore } from '@kit.ArkData';

@ObservedV2
export class AccountEntity {
  userId: string = '';
  @Trace displayName: string = '';
  @Trace balance: number = 0;
}

// 写入:把实体直接当 ValuesBucket 用
await store.insert('account', entity as object as relationalStore.ValuesBucket);

// 查询:用属性名取列下标
const idx = rs.getColumnIndex('displayName');
const name = rs.getString(idx);

这段代码的问题不在语法,而在于它假设了两件事:属性名在运行时不会被改写、属性名恰好等于数据库列名。开启 -enable-property-obfuscation 之后,两个假设同时破灭。

三、细节解析

3.1 混淆到底改了哪些名字

ArkGuard 的混淆能力是按开关逐层打开的,官方《应用代码混淆》给出的选项清单与起始版本如下:

选项作用起始 API
-enable-property-obfuscation属性混淆—
-enable-toplevel-obfuscation顶层作用域名称混淆—
-enable-filename-obfuscation文件名混淆—
-enable-export-obfuscationexport 导出名称与属性混淆—
-keep-property-name保留属性名10
-keep-global-name保留顶层作用域与导出元素名称10
-keep-file-name保留文件/文件夹名称10
-keep-comments保留注释12
-keep-dts保留指定 .d.ts 中所有名称12
-keep保留指定源码文件中所有名称12
名称类/路径类通配符支持 ? * ** !12
-keep-uncompact代码压缩时排除指定路径26.0.0

关键在于:DevEco Studio 生成的 obfuscation-rules.txt 模板默认就开启了 -enable-property-obfuscation、-enable-toplevel-obfuscation、-enable-filename-obfuscation、-enable-export-obfuscation 四个选项。也就是说,只要你把 enable 改成 true,属性名混淆就已经是默认生效的,不需要额外动作。很多人以为"我只是开了混淆,没开属性混淆",这是误判。

3.2 属性名为什么会漏进数据库

relationalStore 的写入接口第二个参数是 ValuesBucket,语义是 Record<string, ValueType>——它的键就是列名。你把实体对象强转后传进去,等于把「ArkTS 属性名」直接当成了「数据库列名」交给 SQL 引擎。属性名一混淆,SQL 里的列名就变成 a1、a2。

官方文档把这条写得很直白。《ArkGuard 混淆保留选项》在 -keep-property-name 条目下专门写了一句:“使用到的数据库相关的字段,需要手动保留。例如,数据库键值对类型(ValuesBucket)中的属性”;《ArkGuard 混淆开启指南》进一步给出统一口径:“若在代码中使用点语法访问未在 ArkTS/TS/JS 代码中定义的字段,比如访问 native 实现的 so 库、字段固定的 json 文件与数据库等场景……若在代码中使用数据库相关的字段,需要使用 -keep-property-name 保留数据库中的字段名称。”

同一节还列出了另外两类必须手写白名单的场景:

  • 代码里静态定义的属性被动态访问(字符串拼接构造属性名、方括号取值),或动态定义、静态访问;
  • 代码里用点号访问未在 ArkTS/TS/JS 中定义的字段(so 库、固定格式 JSON、数据库)。

所以"漏 keep"确实是官方认可的一条修法。但它是兜底,不是设计。因为 -keep-property-name 有一条容易被忽略的约束:属性白名单作用于全局,代码中多个重名属性只要与配置的名字相同,全部不会被混淆。 你保留 id、name、type、status 这些大众列名,等于在全局范围内放行了一大批同名属性,混淆收益被静默吃掉——这份代价在构建日志里看不到,只有对着 nameCache.json 才能发现。

3.3 @Trace 失效是同一类病

@ObservedV2 + @Trace 走的是 V2 状态管理:观测关系建立在属性名这一层标识上。名字被改写后,赋值仍然成功(对象字段确实变了),但变更通知找不到原来的观测点,于是"数据改了、UI 不动",且不抛异常。

它和数据库问题是同一根因的两种表现:凡是"跨进程、跨版本、跨构建产物"必须保持稳定的名字,都不能依赖会被混淆改写的属性名。 数据库的稳定性要求来自磁盘上已有的表结构,@Trace 的稳定性要求来自编译期建立的元数据;两者的解法也一致:要么保留名字,要么不让名字成为契约。

顺带说一句排查技巧:这两个问题要拆开验证,别混在一起查。一个最小页面 + 一个 @ObservedV2 类 + 两三个 @Trace 字段 + 一个 setInterval 改值,打 Release 混淆包跑一遍,能不能刷新立刻可见;数据库侧则单独跑一次 CREATE TABLE + insert + query。两分钟能把"UI 不刷新"到底是观测问题还是数据问题分干净。

3.4 -keep 不是更好的答案

既然 -keep-property-name 是全局放行,那改用 -keep ./src/main/ets/model 按目录保留是不是更精准?恰恰相反,它更粗。

-keep 保留的是指定相对路径下所有名称(变量名、类名、属性名、方法名全都保),而且存在依赖链连带效应——引用它的其他文件中的导出名也可能被一并保留。你以为只放行了一个实体类,实际放行的是一片。相比之下,按名字白名单(-keep-property-name)只损失"必要的那些名字",粒度反而更细。

正确的取舍是:先用显式装配把名字从契约里拿掉,剩下真正跑不掉的(比如反射式反序列化、@Type 嵌套字段)再进名字白名单,名单由脚本生成而不是手写。

3.5 HAR/HSP 会把这个坑放大一倍

如果实体类位于 HAR 模块,问题还会多一层。官方的文件职责划分是:

  • obfuscation-rules.txt:本模块编译时生效,不影响依赖方;
  • consumer-rules.txt:不影响本模块,只在本模块被其他模块依赖时生效,构建后打包为 HAR 内的 obfuscation.txt;
  • obfuscation.txt:构建产物,依赖方编译时解析并合并其白名单。

由此有两条必须遵守的实践:

  1. HAR 里对外暴露的实体字段、需要保留的属性名,必须写进 consumer-rules.txt,否则你本地测全绿,接入方一混淆就塌;
  2. 官方特别提示:对于三方库,obfuscation.txt 只有在模块级 oh-package.json5 中依赖该库时才生效;若依赖配置在工程级 oh-package.json5,该库的 obfuscation.txt 不生效。 这是"引入的 SDK 自己的白名单没起作用"的常见原因。

另外,consumerFiles 字段仅在 HAR/HSP 模块可配置,主模块配了也无效。

3.6 构建配置的落点

混淆配置在模块级 build-profile.json5 的 buildOptionSet 里,官方字段说明:ruleOptions.enable 为必填布尔值,默认 false(DevEco Studio 5.0.3.600 及以上新建工程默认关闭混淆);files 为字符串数组、可配多个文件、默认为 ./obfuscation-rules.txt。

files 支持多文件这一点很实用:人工维护的规则和脚本生成的名单可以分开放**,后者进 .gitignore,避免每次改实体都要人工同步一份几百行白名单。

四、示例代码

4.1 第一刀:让列名成为字符串常量,实体只做搬运

这是本文最重要的一段代码。原则只有一句:ValuesBucket 的键来自常量,不来自属性名。

// db/account-schema.ets —— 列名契约集中一处定义
import { relationalStore } from '@kit.ArkData';

/** 列名常量:SQL、ValuesBucket、ResultSet 全部从这里取,混淆改不到字符串 */
export class AccountCol {
  static readonly TABLE: string = 'account';
  static readonly ID: string = 'id';
  static readonly USER_ID: string = 'user_id';
  static readonly DISPLAY_NAME: string = 'display_name';
  static readonly BALANCE: string = 'balance';
}

/** 建表语句全部由常量拼装,避免"SQL 里写一个名、代码里写另一个名" */
export const CREATE_ACCOUNT_SQL: string =
  `CREATE TABLE IF NOT EXISTS ${AccountCol.TABLE} (` +
  `${AccountCol.ID} INTEGER PRIMARY KEY AUTOINCREMENT, ` +
  `${AccountCol.USER_ID} TEXT NOT NULL, ` +
  `${AccountCol.DISPLAY_NAME} TEXT, ` +
  `${AccountCol.BALANCE} REAL DEFAULT 0)`;

/** 领域实体:属性名可以随便混淆,它只服务于业务逻辑 */
export class AccountEntity {
  userId: string = '';
  displayName: string = '';
  balance: number = 0;
}

/** 实体 → ValuesBucket:显式装配,杜绝"属性名即列名"的隐式依赖 */
export function toBucket(e: AccountEntity): relationalStore.ValuesBucket {
  const bucket: relationalStore.ValuesBucket = {};
  bucket[AccountCol.USER_ID] = e.userId;
  bucket[AccountCol.DISPLAY_NAME] = e.displayName;
  bucket[AccountCol.BALANCE] = e.balance;
  return bucket;
}

/** ResultSet → 实体:按列名取下标,不按属性名 */
export function fromRow(rs: relationalStore.ResultSet): AccountEntity {
  const e = new AccountEntity();
  e.userId = rs.getString(rs.getColumnIndex(AccountCol.USER_ID));
  e.displayName = rs.getString(rs.getColumnIndex(AccountCol.DISPLAY_NAME));
  e.balance = rs.getDouble(rs.getColumnIndex(AccountCol.BALANCE));
  return e;
}

多出来的这层转换代码换来三件事:属性名混淆后旧表无需迁移、@Trace 与列名解耦、列名成为可全局搜索的单一事实源。查询侧同样受益:

// 全部用列名常量拼 SQL,禁用 for...in 遍历实体拼字段名
const predicates = new relationalStore.RdbPredicates(AccountCol.TABLE);
predicates.equalTo(AccountCol.USER_ID, userId);
const rs = await store.query(predicates, [AccountCol.ID, AccountCol.DISPLAY_NAME, AccountCol.BALANCE]);
const list: AccountEntity[] = [];
while (rs.goToNextRow()) {
  list.push(fromRow(rs));
}
rs.close();

有一条红线:禁止 for (const k in entity) { bucket[k] = entity[k] }。 这是把属性名当列名的另一种写法,只不过藏得更深,混淆后一样炸。

4.2 第二刀:白名单交给脚本生成

把"哪些名字必须保留"从人工判断变成机械提取:扫源码里带 @Trace / @Type 装饰器的属性,以及 SQL 语句里出现的列名,生成 -keep-property-name 清单。

// scripts/gen-entity-keep.mjs —— node scripts/gen-entity-keep.mjs
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';

const SRC = 'entry/src/main/ets';                       // 扫描根目录
const OUT = 'entry/entity-keep-rules.txt';              // 生成产物,加入 .gitignore

const DECORATED = /@(?:Trace|Type)\s*(?:\r?\n\s*)?([A-Za-z_$][\w$]*)\s*[?!]?\s*:/g;
const SQL_COLS = /(?:INSERT\s+(?:OR\s+\w+\s+)?INTO|CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?)\s+\w+\s*\(([^)]*)\)/gi;

const names = new Set();

function walk(dir) {
  for (const f of readdirSync(dir)) {
    const p = join(dir, f);
    if (statSync(p).isDirectory()) { walk(p); continue; }
    if (!['.ets', '.ts'].includes(extname(p))) { continue; }
    const code = readFileSync(p, 'utf8');
    for (const m of code.matchAll(DECORATED)) { names.add(m[1]); }
    for (const m of code.matchAll(SQL_COLS)) {
      for (const col of m[1].split(',')) {
        const t = col.trim().split(/\s+/)[0];
        if (/^[A-Za-z_$][\w$]*$/.test(t)) { names.add(t); }
      }
    }
  }
}

walk(SRC);
writeFileSync(OUT,
  '# auto-generated by gen-entity-keep.mjs, do not edit\n' +
  [...names].sort().map((n) => `-keep-property-name ${n}`).join('\n') + '\n');
console.log(`kept ${names.size} names -> ${OUT}`);

建议把它做成打包流程里的一个固定步骤(hvigorw assembleHap 之前先跑 node scripts/gen-entity-keep.mjs),或交给 CI 编排。注意官方明确提示目前不支持在 hvigor 构建流程中添加自定义混淆插件,所以别指望写个 hvigor 插件自动挂上——把它放在构建命令的前一行,是当前版本下最稳的做法。

另外,“混淆后的远程 HAR 包被某模块依赖,如果该模块开启混淆,HAR 包会被二次混淆”——这也是 HAR 场景必须配 consumer-rules.txt 的直接原因。

4.3 构建配置:规则文件拆成两份

// entry/build-profile.json5
{
  "apiType": "stageMode",
  "buildOptionSet": [
    {
      "name": "release",
      "arkOptions": {
        "obfuscation": {
          "ruleOptions": {
            "enable": true,
            "files": [
              "./obfuscation-rules.txt",        // 人工维护:开关 + 零散规则
              "./entity-keep-rules.txt"         // 脚本生成:实体/SQL 名字白名单
            ]
          },
          "consumerFiles": ["./consumer-rules.txt"]  // 仅 HAR/HSP 可配置
        }
      }
    }
  ],
  "targets": [{ "name": "default" }]
}

4.4 验收:别只看"构建成功"

构建成功与运行正确是两件事。Release 混淆包的验收要落到产物上:官方说明混淆中间产物位于模块的 build/default/[target]/release/obfuscation/ 目录下,包含 nameCache.json(名称映射表)与 config.json(混淆项与白名单)。用脚本扫一遍映射表,确认列名没被改写:

// scripts/check-namecache.mjs(Node 环境,读构建产物)
import { readFileSync } from 'node:fs';

const CACHE = 'entry/build/default/outputs/default/obfuscation/nameCache.json';
const RISKY = ['user_id', 'display_name', 'balance'];   // 必须原样存在的列名

const raw = JSON.parse(readFileSync(CACHE, 'utf8'));
// nameCache 的顶层结构随版本有调整,先打印第一层 key 再往里找
const flat = JSON.stringify(raw);

for (const name of RISKY) {
  const renamed = new RegExp(`"${name}"\\s*:\\s*"([^"]+)"`).exec(flat);
  const verdict = renamed ? `被改写为 ${renamed[1]}(异常)` : '未出现在映射表中(正常:已被白名单保留)';
  console.log(`${name}: ${verdict}`);
}

配合另外三条经验,基本可以把这类问题挡在上线前:

  1. 用旧版本安装包升级到新包跑一次完整读写,能抓到"列名是运行时算出来的"那类隐藏坑;
  2. 混淆包要跑插入 → 查询 → UI 刷新全链路,只验 insert 会漏掉读取映射错误;
  3. 若实体在 HAR 里,把 consumer-rules.txt 的生效情况单独验一遍——找一个混淆配置不同的宿主工程做对照。

五、总结

把结论压缩成一张可执行清单:

根因判断

  • Release 才出问题、Debug 正常 → 优先怀疑混淆(enable 默认 false,且只在 release 生效);
  • 数据库报 no column 或字段读成 null + UI 该刷新不刷新 → 同一根因的两种表现,先拆开单独验证。

改造顺序(按收益从高到低)

  1. 断开"属性名 = 列名":列名用字符串常量,实体与 ValuesBucket/ResultSet 之间加显式装配层。这一步做完,大部分报错直接消失,且不需要改混淆规则;
  2. @Trace 与持久化分离:数据库列用 snake_case,UI 状态类用 camelCase,由装配层转换,两者的稳定性要求不再互相牵连;
  3. 白名单脚本生成:扫 @Trace/@Type 属性和 SQL 列名,输出 -keep-property-name 清单,通过 ruleOptions.files 与人工规则并存;
  4. 慎用 -keep:它按路径保留所有名字且有连带效应,比名字白名单更粗;
  5. HAR/HSP 补 consumer-rules.txt:否则接入方一混淆就复现你刚修好的问题。

三条容易踩的坑

  • -keep-property-name 只在开启 -enable-property-obfuscation 时生效,且全局生效——保 id、name 这类大众名字会静默吃掉一大片混淆收益;
  • DevEco 生成的 obfuscation-rules.txt 模板默认已开四个混淆选项,"我没开属性混淆"通常是误判;
  • 三方库的 obfuscation.txt 只在模块级 oh-package.json5 依赖时生效,工程级依赖不生效。

最后回到本文的标题:混淆不是敌人,把易变的名字当契约才是。名字必须稳定的地方(磁盘上的表结构、跨版本的数据协议、编译期建立的观测点),就用字符串常量和显式映射把契约钉死;名字可以自由的地方(业务对象的属性),就放手让混淆器去改。这条边界划清楚之后,-keep-property-name 从"永远补不完的名单"变成一份可控的、几十行的例外清单。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐