写这篇文章的那天,工程里的 ArkTS 诊断板刚从 27 条红色飘红变成 0,然后 hvigor assembleHap 又因为 deviceTypes 不匹配炸了一下。这篇就是把完整踩坑过程整理出来:从手表项目中途切换方向写手机屏心情日记 App,到一口气爆 27 条 ArkTS 错误,再通过「纯方法下沉 + 严格显式类型 + 资源解耦 + 配置文件最小改动」一路把错误全清,并让 requiredDeviceType=wearablerequiredDeviceType=phone 两种构建目标都能通过。


在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

一、背景:一个鸿蒙工程的方向切换

本文的工程目录是 D:\HarmonyOSProject\shoubiao,最早的立项其实是为了一块手表(wearable)——想做炫彩流体渐变数字时钟,带呼吸光圈、渐变秒表、居中大号时间那种酷酷的风格。时钟版本做到一半,我们做了不少有意思的事:用 Circle + linearGradient 代替不存在的 sweepGradient 重载,用 strokeDashOffset 实现弧形秒针,把径向渐变光圈的直径、stop 区间反复拉来拉去……但最终因为各种颜色组合、字号、呼吸灯定位、秒数不跳等细节反复调了几轮,用户干脆说:

「换一个应用,写一个能在屏幕上记心情的手机应用。」

于是同一套工程根目录、同一套签名配置、同一个 entry 模块,我们直接把内容整个切换到心情日记 App。这里其实埋了两个潜在炸点

  1. 模块配置:手表项目的 module.json5:deviceTypes 本来是 ["wearable"],为了做手机心情日记,我直接改成了 ["phone"];但 DevEco Studio 的构建缓存 / 运行配置里还保留着之前的 --requiredDeviceType=wearable 参数。
  2. SDK 语法约束:ArkTS 有大量和标准 TypeScript 不兼容的地方(不允许 any/unknown、不允许 @Builder 内写 let、不允许解构、不允许 Function.apply/bind 等)。一旦按 TS 习惯写,IDE 诊断板会直接爆炸。

最终我们交付的心情日记 App 长这样:

能力 说明
7 档心情 😵‍💫崩溃 / 😞沮丧 / 😟焦虑 / 😐平静 / 😌放松 / 😄开心 / 🥳狂喜,每档独立配色、emoji、国际化文案
输入 当天心情点选(220ms animateTo 平滑切换) + 自由文字笔记
持久化 @kit.ArkData Preferences 本地持久化;14 天滚动保留;当天可重复覆盖;toast 反馈
数据展示 7 天渐变柱状图(线性 gradient 从上到下由浓变淡) + 7 天倒序列表(今天高亮)+ 顶部平均心情 emoji + 记录总数
视觉 紫粉治愈系配色,梦幻模糊彩球两团(橙 + 青)+ 卡片圆角阴影 + Scroll 弹簧回弹
双主题 resources/base 浅色 + resources/dark 深色,共 20 个颜色项镜像
兼容性 IDE 诊断 0 错误;hvigor 构建 wearable 和 phone 两种 requiredDeviceType 都通过

工程文件结构大致如下(只列关键文件):

shoubiao/
├─ build-profile.json5            // 根级:signingConfigs / products / modules
├─ entry/
│  ├─ build-profile.json5         // 模块级:apiType stageMode / targets
│  └─ src/main/
│     ├─ module.json5             // 【关键】deviceTypes 双支持 phone + wearable
│     ├─ ets/
│     │  ├─ entryability/EntryAbility.ets
│     │  └─ pages/Index.ets       // 【核心】814 行完整实现,ArkTS 0 错误
│     └─ resources/
│        ├─ base/element/{string.json, color.json}
│        └─ dark/element/color.json

二、踩坑复盘:IDE 一次性抛 27 条 ArkTS 错误的根因

切换完心情日记代码的第一版后,IDE 诊断板直接报了 27 条错误,类型五花八门。把它们按类别统计一下,会发现几乎全部来自 ArkTS 对 TypeScript 动态特性的严格裁剪

错误类别 条数 典型错误信息
Resource vs string 类型不兼容 1 Type Resource is not assignable to type string
访问不存在的属性 2 Context.configuration 在当前 SDK 不存在;对象字面量无类型
TextInput placeholder 链式调用 1 TextInput({...}).placeholder(...) 不合法,placeholder 必须进构造参数
@Builder 内写 let 声明局部变量 19 Only UI component syntax can be written here
未使用的类 1 MoodMeta 多余类
函数可能抛异常 + put 异步回调写法 1 preferences.put 回调改 putSync+flushSync 整体 try/catch
合计 24 其余 3 条与上述连锁触发(如变量类型推断失败衍生错误)

很多鸿蒙新手第一次看到这种诊断板会懵:「我 TypeScript 写得好好的,怎么全报了?」答案是:ArkTS 不是 TypeScript 的超集,它是一个做了严格静态化裁剪的子类型系统。下面我按错误点逐个讲清楚,并展示我怎么用「最小改动闭环」原则把它们全清掉。


三、核心代码点 ①:严格显式类型 + 数据模型

ArkTS 第一条红线:不允许 any、unknown;第二条红线:对象字面量必须能推断到一个显式声明的类或接口。这意味着,任何你想从 Preferences 里读到、或者想塞到 @State records[] 里的结构,都必须先 class 出来。

我先定义了一个全字段带默认值、完全没有类型含糊的数据模型:

// Index.ets:L4-L10
class MoodRecord {
  date: string = '';        // YYYY-MM-DD
  mood: number = 3;         // 0~6
  note: string = '';        // 笔记
  updatedAt: number = 0;    // 时间戳 ms
}

这么写有三个好处:

  1. 字段有默认值,避免了「确定性赋值断言」语法 !(ArkTS 不推荐用 !,会加运行时类型检查)。
  2. 每条字段的类型都明确是原生类型string / number),后续塞到 @State records: MoodRecord[] 里不会触发 arkts-no-untyped-obj-literals
  3. 后面用 new MoodRecord() 生成对象时,编译器能确定对象布局,ArkTS 的静态假设才成立。

然后是 UI 组件本身的 @State 字段,同样全部显式类型、全部初始化:

// Index.ets:L14-L55
@Entry
@Component
struct Index {
  @State selectedMood: number = 3;
  @State noteText: string = '';
  @State records: MoodRecord[] = [];
  @State todayStr: string = '';
  @State weekdayRes: Resource = $r('app.string.week_monday');
  @State hasTodayRecord: boolean = false;
  @State todayMood: number = -1;
  @State todayNote: string = '';
  @State trendDays: string[] = ['', '', '', '', '', '', ''];
  @State last7Reverse: number[] = [6, 5, 4, 3, 2, 1, 0];
  @State averageMoodIdx: number = -1;

  private prefStore: preferences.Preferences | null = null;
  private readonly PREF_STORE: string = 'mood_diary_store';
  private readonly PREF_KEY: string = 'mood_records';
  private readonly MOOD_EMOJIS: string[] = ['😵‍💫', '😞', '😟', '😐', '😌', '😄', '🥳'];
  private readonly MOOD_NAMES: Resource[] = [
    $r('app.string.mood_0'),
    $r('app.string.mood_1'),
    // ... mood_2..mood_6 省略
  ];
  // ...

注意这里有个非常关键的点:weekdayRes: Resource,而不是 string。这是我修复 27 条错误里第 1 条(Resource vs string 不兼容)的决策:既然 HarmonyOS 官方文案必须走 $r('app.string.xxx'),而 $r 的返回值是 Resource 类型,那就一路把 Resource 传到底,不要试图在中间把 Resource 转成 string。后面在 HeaderBuilder() 里,我把日期文本和星期文本直接拆成两根独立的 Text()

// Index.ets:L401-L408
Column({ space: 2 }) {
  Text(this.todayStr)
    .fontSize(12)
    .fontColor($r('app.color.text_secondary'))
  Text(this.weekdayRes)           // 直接把 Resource 喂给 Text 子节点
    .fontSize(12)
    .fontColor($r('app.color.text_secondary'))
    .margin({ bottom: 4 })
  Text($r('app.string.app_title'))
    .fontSize(28)
    .fontWeight(FontWeight.Bold)
    .fontColor($r('app.color.text_primary'))
}

这样就彻底避免了 Resourcestring 的赋值错误,也避免了老代码里想 "" + $r('...') 拼接字符串然后报错的那种写不下去的尴尬。


四、核心代码点 ②:Preferences 持久化手写序列化 + 运行时校验

心情日记 App 需要把记录存到本地。HarmonyOS 官方的轻量持久化方案是 @kit.ArkData 包里的 preferences,用法的大致流程是:

  1. preferences.getPreferences(context, name, callback) 拿到 store;
  2. store.get(key, defaultValue) 读;store.putSync(key, value) / store.flushSync() 写。

我在第一版里犯了两个典型错误:用 store.put 回调风格写法(ArkTS 编译器直接给了个「函数可能抛异常」的强提示,要求用 try/catch 包好),以及直接 JSON.parse 后把结果直接扔给 MoodRecord[](无类型校验 + 任何字段缺失都会炸)。

我的修复做法是:手写 JSON 序列化 + 反序列化全程做 instanceof / typeof 硬校验。这样既符合 ArkTS 「静态化、无 any、无动态字段」的约束,也能让脏数据被优雅地跳过而不是崩溃。

4.1 读数据:loadRecords → parseRecords → syncTodayState

// Index.ets:L205-L230
private loadRecords(): void {
  try {
    let context: Context = getContext(this);
    preferences.getPreferences(context, this.PREF_STORE, (err: Error, store: preferences.Preferences): void => {
      if (err) {
        this.records = [];
        this.syncTodayState();
        return;
      }
      this.prefStore = store;
      store.get(this.PREF_KEY, '[]', (err2: Error, val: preferences.ValueType): void => {
        if (err2) {
          this.records = [];
          this.syncTodayState();
          return;
        }
        let raw: string = val as string;
        this.records = this.parseRecords(raw);
        this.syncTodayState();
      });
    });
  } catch (_e) {
    this.records = [];
    this.syncTodayState();
  }
}

注意:preferences.ValueType 是 ArkData 定义的联合类型(可以是 string / number / boolean 等),这里我们只放字符串 JSON,所以用 val as string 收,这是合法的「已知具体类型的显式断言」,ArkTS 允许。

然后是 parseRecords,这一段是整个工程里类型约束最严格的地方:

// Index.ets:L232-L270
private parseRecords(raw: string): MoodRecord[] {
  if (raw.length === 0) { return []; }
  let trimmed: string = raw.trim();
  if (trimmed.length < 2) { return []; }
  let parsed: object = JSON.parse(trimmed) as object;
  let result: MoodRecord[] = [];
  if (!(parsed instanceof Array)) { return []; }
  let arr: object[] = parsed as object[];
  for (let i: number = 0; i < arr.length; i++) {
    let item: object = arr[i];
    if (!(item instanceof Object)) { continue; }
    let dict: Record<string, Object> = item as Record<string, Object>;
    let d: Object | undefined = dict['date'];
    let m: Object | undefined = dict['mood'];
    let n: Object | undefined = dict['note'];
    let u: Object | undefined = dict['updatedAt'];
    if (typeof d !== 'string' || typeof m !== 'number') { continue; }
    let rec: MoodRecord = new MoodRecord();
    rec.date = d;
    let mn: number = m as number;
    if (mn < 0) { mn = 0; }
    if (mn > 6) { mn = 6; }
    rec.mood = mn;
    rec.note = typeof n === 'string' ? (n as string) : '';
    rec.updatedAt = typeof u === 'number' ? (u as number) : 0;
    result.push(rec);
  }
  return result;
}

这段逐行看都在踩 ArkTS 的红线边缘,我们把每一步都做了「先断言合法,再往下走」:

  • JSON.parse 返回的是 object 类型(不能是 any):我先把返回值写 as object,再用 instanceof Array 验证它是不是数组。
  • 不允许 for..in:老代码如果想 for (const k in item) 遍历字段,ArkTS 直接报错。所以我改成「所有字段名写死,显式逐个 dict['date'] / dict['mood'] 取」——用 Record<string, Object> 作为容器类型,取出来的值都是 Object | undefined,再用 typeof 判断是不是 string / number
  • 任何字段缺失都跳过那一条:不满足就 continue,宁可少读一条也不能把坏数据塞进 records

紧接着是 syncTodayState,它做了三件事:裁剪 14 天滚动窗口、按日期排序、把今天的记录回显到 UI 状态:

// Index.ets:L272-L305
private syncTodayState(): void {
  let trimmed: MoodRecord[] = [];
  let todayRec: MoodRecord | null = null;
  let cutDate: Date = new Date();
  cutDate.setDate(cutDate.getDate() - 13);
  let cutKey: string = this.formatDate(cutDate);
  for (let i: number = 0; i < this.records.length; i++) {
    let r: MoodRecord = this.records[i];
    if (r.date >= cutKey) { trimmed.push(r); }
    if (r.date === this.todayStr) { todayRec = r; }
  }
  trimmed.sort((a: MoodRecord, b: MoodRecord): number => {
    if (a.date < b.date) { return -1; }
    if (a.date > b.date) { return 1; }
    return 0;
  });
  this.records = trimmed;
  this.refreshAverage();
  if (todayRec !== null) {
    this.hasTodayRecord = true;
    this.todayMood = todayRec.mood;
    this.todayNote = todayRec.note;
    this.selectedMood = todayRec.mood;
    this.noteText = todayRec.note;
  } else {
    this.hasTodayRecord = false;
    this.todayMood = -1;
    this.todayNote = '';
  }
}

14 天窗口的写法很讨巧:cutDate = 今天 - 13 天,然后只要 r.date >= cutKey 就留——由于 dateYYYY-MM-DD 格式的字符串,字符串字典序等于日期序,直接用 >= 就能比较,不需要再转 Date

4.2 写数据:手写 JSON 字符串(为什么不用 JSON.stringify

「你为什么不直接 JSON.stringify(this.records) 呢?」——这是第一次看到我这段代码的人必问的问题。原因很简单:ArkTS 对「对象作为 JSON.stringify 参数」有严格限制,而且我的 note 字段可能包含 \ " \n \r \t 这些需要转义的字符。为了在不引入 any、不调用 Function.apply/bind、不依赖动态字段遍历的前提下把数据序列化,我干脆手写了一个安全的 JSON 拼装:

// Index.ets:L343-L381
private escapeJson(s: string): string {
  let out: string = '';
  for (let i: number = 0; i < s.length; i++) {
    let c: string = s.charAt(i);
    if (c === '\\') { out += '\\\\'; }
    else if (c === '"') { out += '\\"'; }
    else if (c === '\n') { out += '\\n'; }
    else if (c === '\r') { out += '\\r'; }
    else if (c === '\t') { out += '\\t'; }
    else { out += c; }
  }
  return out;
}

private persist(): void {
  if (this.prefStore === null) { return; }
  let arr: string[] = [];
  for (let i: number = 0; i < this.records.length; i++) {
    let r: MoodRecord = this.records[i];
    let escNote: string = this.escapeJson(r.note);
    let s: string = '{' +
      '"date":"' + r.date + '",' +
      '"mood":' + r.mood.toString() + ',' +
      '"note":"' + escNote + '",' +
      '"updatedAt":' + r.updatedAt.toString() +
      '}';
    arr.push(s);
  }
  let json: string = '[' + arr.join(',') + ']';
  try {
    let store: preferences.Preferences = this.prefStore;
    store.putSync(this.PREF_KEY, json);
    store.flushSync();
  } catch (_e) {
    // 忽略写失败
  }
}

这里用的是 putSync + flushSync 的同步 API(注意 ArkTS catch 子句不能写类型标注,必须写成 catch (_e),因为 ArkTS 不支持 catch (e: any)——它根本不支持 any)。同步 API 的优点是可控、写操作要么成功要么失败直接吞掉,不会再给诊断板塞「callback 可能抛异常」的提示。

保存按钮调用时,我做的是「合并当天记录 + 整体排序 + 重新计算平均心情 + persist + toast」的组合拳:

// Index.ets:L307-L341
private saveToday(): void {
  let now: number = Date.now();
  let target: MoodRecord = new MoodRecord();
  target.date = this.todayStr;
  target.mood = this.selectedMood;
  target.note = this.noteText;
  target.updatedAt = now;

  let nextList: MoodRecord[] = [];
  let found: boolean = false;
  for (let i: number = 0; i < this.records.length; i++) {
    let r: MoodRecord = this.records[i];
    if (r.date === this.todayStr) {
      nextList.push(target);
      found = true;
    } else {
      nextList.push(r);
    }
  }
  if (!found) { nextList.push(target); }
  nextList.sort((a: MoodRecord, b: MoodRecord): number => {
    if (a.date < b.date) { return -1; }
    if (a.date > b.date) { return 1; }
    return 0;
  });
  this.records = nextList;
  this.hasTodayRecord = true;
  this.todayMood = target.mood;
  this.todayNote = target.note;
  this.refreshAverage();
  this.persist();
  this.flashToast($r('app.string.saved_toast'));
}

private flashToast(msg: Resource): void {
  try {
    promptAction.showToast({
      message: msg,
      duration: 1500,
      bottom: 140
    });
  } catch (_e) { /* 忽略 */ }
}

注意 promptAction.showToastmessage 参数是 Resource,和我之前「不把 Resource 转 string」的决策保持一致——直接把 $r('app.string.saved_toast') 穿过去,IDE 不会报错,而且深浅主题、多语言都自然可用。


五、核心代码点 ③:所有 @Builder 里的逻辑,全部下沉到纯方法

这是 27 条错误里最重磅的一部分(占 19 条)。ArkUI 的 @Builder 装饰器里,只允许写 UI 组件语法——你如果在里面写 let r = this.findRecordFor(dateKey)let m = r === null ? -1 : r.mood,编译器会一条条给你报 Only UI component syntax can be written here

新手的直觉反应是「那我就在组件旁边用三元运算符吧」,但三元一旦层层嵌套,可读性会爆炸,而且 ArkTS 三元里也不能写复杂语句。我用的方案是:把所有「某一天是否有记录 / 当天心情是几 / 当天笔记 / 柱状图高度 / 柱状图颜色」等派生值,全部写成本组件的私有纯方法(pure method),UI 层一行一行直接调用

我在 Index.ets 里列了整整 7 个查询工具方法(L134-L176):

// Index.ets:L134-L176
private findRecordFor(dateKey: string): MoodRecord | null {
  for (let i: number = 0; i < this.records.length; i++) {
    let r: MoodRecord = this.records[i];
    if (r.date === dateKey) { return r; }
  }
  return null;
}
private hasRecord(dateKey: string): boolean {
  return this.findRecordFor(dateKey) !== null;
}
private moodOf(dateKey: string): number {
  let r: MoodRecord | null = this.findRecordFor(dateKey);
  if (r === null) { return -1; }
  return r.mood;
}
private noteOf(dateKey: string): string {
  let r: MoodRecord | null = this.findRecordFor(dateKey);
  if (r === null) { return ''; }
  return r.note;
}
private barRatio(dateKey: string): number {
  let m: number = this.moodOf(dateKey);
  if (m < 0) { return 0.0; }
  return (m + 1) / 7;
}
private barHeightPx(dateKey: string): number {
  let ratio: number = this.barRatio(dateKey);
  if (ratio <= 0.0) { return 8; }
  return Math.round(ratio * 120);
}
private barColor(dateKey: string): string {
  let m: number = this.moodOf(dateKey);
  if (m < 0) { return '#C4B9E0'; }
  return this.moodColor(m);
}

这样一来,后面在 TrendBar 里写柱状图时,UI 语法就变得纯粹且零错误

// Index.ets:L641-L670
@Builder
private TrendBar(dateKey: string) {
  Column({ space: 0 }) {
    Blank()
    Column() { }
      .width(22)
      .height(this.barHeightPx(dateKey))       // 纯方法:返回具体像素高度
      .borderRadius(11)
      .opacity(this.hasRecord(dateKey) ? 1.0 : 0.25)
      .linearGradient({
        angle: 180,
        colors: [
          [this.barColor(dateKey), 0],
          [this.soften(this.barColor(dateKey), 0.45), 1]
        ]
      })
      .margin({ bottom: 2 })
  }
  .width(36)
  .height(130)
  .alignItems(HorizontalAlign.Center)
  .justifyContent(FlexAlign.End)
  .borderRadius(12)
  .backgroundColor(
    dateKey === this.todayStr
      ? $r('app.color.accent_primary_soft')
      : 'rgba(0,0,0,0)'
  )
  .padding({ top: 4, bottom: 4 })
}

再看一个更复杂的例子:历史列表行 RecentRow。如果按 TS 写法,里面肯定会有好几处 let moodIdx = ...let note = ... 的局部变量。在 ArkTS 里这些全部换成方法调用,且 if / else 分支只能切换子组件不能切代码块:

// Index.ets:L683-L773
@Builder
private RecentRow(dateKey: string) {
  Row({ space: 12 }) {
    Column({ space: 2 }) {
      Text(this.mdOnly(dateKey))
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(
          dateKey === this.todayStr
            ? $r('app.color.accent_primary')
            : $r('app.color.text_primary')
        )
      if (dateKey === this.todayStr) {
        Row({ space: 4 }) {
          Text(this.getWeekdayResByKey(dateKey))
            .fontSize(11)
            .fontColor($r('app.color.text_secondary'))
          Text('·今天')
            .fontSize(11)
            .fontColor($r('app.color.text_secondary'))
        }
      } else {
        Text(this.getWeekdayResByKey(dateKey))
          .fontSize(11)
          .fontColor($r('app.color.text_secondary'))
      }
    }
    .width(70)
    .alignItems(HorizontalAlign.Start)

    Stack({ alignContent: Alignment.Center }) {
      Circle({ width: 38, height: 38 })
        .fill(
          this.hasRecord(dateKey)
            ? this.soften(this.moodColor(this.moodOf(dateKey)), 0.20)
            : $r('app.color.divider')
        )
      Text(
        this.hasRecord(dateKey)
          ? this.MOOD_EMOJIS[this.moodOf(dateKey)]
          : '·'
      )
        .fontSize(18)
    }

    Column({ space: 2 }) {
      if (this.hasRecord(dateKey)) {
        Text(this.MOOD_NAMES[this.moodOf(dateKey)])
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.moodColor(this.moodOf(dateKey)))
          .width('100%')
      } else {
        Text('')
          .fontSize(12)
          .width('100%')
          .height(16)
      }
      if (this.noteOf(dateKey).length > 0) {
        Text(this.noteOf(dateKey))
          .fontSize(12)
          .fontColor($r('app.color.text_primary'))
          .width('100%')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .opacity(0.9)
      } else if (this.hasRecord(dateKey)) {
        Text($r('app.string.no_note'))
          .fontSize(11)
          .fontColor($r('app.color.text_hint'))
          .width('100%')
          .opacity(0.8)
      } else {
        Text('—')
          .fontSize(12)
          .fontColor($r('app.color.text_hint'))
          .width('100%')
      }
    }
    .layoutWeight(1.0)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(
    dateKey === this.todayStr
      ? $r('app.color.accent_primary_soft')
      : $r('app.color.divider')
  )
  .borderRadius(14)
}

这段 RecentRow没有任何一个 let 声明,全部靠「纯方法返回 + 三元 + if-else 切换组件分支」的组合拳实现。ArkTS 编译器对这种写法是放行的。只要你保持住「@Builder 里只能出现组件声明、属性链和 if/else 组件分支」的纪律,19 条 Only UI component syntax 的错误就不会再出现。

另一个被坑的点:TextInput 的 placeholder 不能链式写

ArkUI 的 TextInput 有个比较隐蔽的 API 差异:placeholder 必须写在构造参数里,不能像 .placeholder($r(...)) 那样 chain。修复前我的写法是:

// ❌ 错误写法(ArkTS 诊断直接报红)
TextInput({ text: this.noteText })
  .placeholder($r('app.string.note_hint'))
  .placeholderColor(...)

修复后,placeholder 移进构造参数对象,placeholderColor 仍然可以 chain(因为 placeholderColor 是链式属性):

// Index.ets:L475-L489 — ✅ 正确写法
TextInput({
  text: this.noteText,
  placeholder: $r('app.string.note_hint')
})
  .width('100%')
  .height(90)
  .fontSize(14)
  .fontColor($r('app.color.text_primary'))
  .placeholderColor($r('app.color.text_hint'))
  .backgroundColor($r('app.color.divider'))
  .borderRadius(16)
  .padding(14)
  .onChange((value: string): void => {
    this.noteText = value;
  })

六、核心代码点 ④:双主题资源 + 七色彩虹配色

一个好看的 App 少不了视觉系统。我给心情日记做了完整的深浅双主题:浅色用紫粉治愈系,深色把主色提亮、卡片底换成深紫。颜色和字符串全部放在 resources 里,用 $r('app.color.xxx') / $r('app.string.xxx') 引用,代码里完全没有硬编码的字面值文案

6.1 浅色主题(resources/base/element/color.json)

资源名 色值 用途
bg_page #F5F1FF 页面背景(淡紫)
bg_card #FFFFFF 卡片底
text_primary #201A33 主文字
text_secondary #756C8F 次文字
text_hint #B6AECD 提示文
divider #ECE7F7 分隔条 / 历史行底
accent_primary #7C5CFF 主紫色(按钮、今日高亮)
accent_primary_soft #E9E1FF 主紫色淡版(TrendBar 今日背景)
mood_0…mood_6 紫→蓝→青→绿→黄→橙→粉 每档心情的渐变色
save_btn_bg / save_btn_text #7C5CFF / #FFFFFF 保存按钮

深色主题(resources/dark/element/color.json)是浅色的镜像对应:

资源名 色值
bg_page #161229
bg_card #221C3B
text_primary #F4EFFF
text_secondary #B5AED1
text_hint #6B6289
divider #2E2749
accent_primary #9D82FF(提亮 20%)
accent_primary_soft #2F2554
mood_0…mood_6 每档都提高亮度(如 #7A6CFF → #9D88FF)
save_btn_bg #9D82FF

对应到代码,所有颜色都走 $r('app.color.*'),只有 7 档心情的渐变色本身我直接在组件里写了 MOOD_COLORS 数组(因为这些颜色在 7 档心情选择器的渐变条上是动态的,浅色和深色需要保持相对亮度,而不是读资源):

// Index.ets:L49-L55
// 7 个心情颜色(在浅/深底通用,避免用 Context 访问 configuration 的类型问题)
private readonly MOOD_COLORS: string[] = [
  '#7C5CFF', '#5B95FF', '#3FBFBF', '#6FCD6F', '#F2B742', '#FF7A50', '#FF4F8F'
];
private readonly SPOT_COLOR_A: string = '#FF7A50';
private readonly SPOT_COLOR_B: string = '#3FBFBF';

这里的注释其实是我从一次失败中总结出来的:最开始我想通过 getContext(this).configuration 里的 colorMode 动态判断深色浅色,结果 ArkTS 报错「该属性不存在」——这种情况说明当前 SDK 版本根本不开放这个字段,硬写就是错。于是我的决策是:主题相关的所有「静态色」都走 $r(让系统根据 resources/base vs dark 自动切),而需要在代码里动态根据心情计算的颜色(比如渐变色条)就用一套在浅/深底都能看的中性色 MOOD_COLORS 直接写死。这个策略让省了两个错误:一个 configuration 属性不存在,一个是 arkts-no-untyped-obj-literals(想从返回值里取字段会触雷)。

6.2 文案系统(resources/base/element/string.json)

字符串资源一共 35 条,完整覆盖心情名、星期、按钮、空状态。节选如下:

{
  "string": [
    { "name": "EntryAbility_label", "value": "心情日记" },
    { "name": "app_title", "value": "心情日记" },
    { "name": "app_subtitle", "value": "记录今天,拥抱自己" },
    { "name": "how_feeling", "value": "你今天感觉怎么样?" },
    { "name": "note_hint", "value": "写下此刻的心情(可留空)…" },
    { "name": "save_btn", "value": "保存今天的心情" },
    { "name": "update_btn", "value": "更新今天的心情" },
    { "name": "saved_toast", "value": "已保存 ✨" },
    { "name": "history_title", "value": "近 7 天心情趋势" },
    { "name": "history_empty", "value": "还没有记录哦,选一个心情开始吧~" },
    { "name": "mood_0", "value": "崩溃" },
    { "name": "mood_1", "value": "沮丧" },
    { "name": "mood_2", "value": "焦虑" },
    { "name": "mood_3", "value": "平静" },
    { "name": "mood_4", "value": "放松" },
    { "name": "mood_5", "value": "开心" },
    { "name": "mood_6", "value": "狂喜" },
    { "name": "week_monday",    "value": "周一" },
    { "name": "week_tuesday",   "value": "周二" },
    { "name": "week_wednesday", "value": "周三" },
    { "name": "week_thursday",  "value": "周四" },
    { "name": "week_friday",    "value": "周五" },
    { "name": "week_saturday",  "value": "周六" },
    { "name": "week_sunday",    "value": "周日" }
  ]
}

注意这里每条文案的名字都严格是 app_* / mood_* / week_* 命名空间,不会和系统资源冲突。如果以后要做英文或繁体,只要在 resources/zh_Hant/element/string.jsonresources/en_US/element/string.json 之类的目录下放一份镜像即可,代码里一行都不用改。


七、UI 结构拆解 + 动画约束遵守(animateTo + 不动布局属性)

ArkUI 动画规范里有一条死规定:动画过程中不要频繁改 width/height/padding/margin 布局属性,严重影响性能。我在做 7 个心情球的点击切换时,严格遵守了这条规范——只用 animateTo 改变一个状态变量 selectedMood,所有视觉差异(球大小、球颜色、球内 emoji 大小、名字颜色、字重)都靠同一个 @State 变量派生出来,动画系统只需要重新执行一次 animateTo 回调里的赋值就够了。

下面是心情球 MoodChip 的完整实现:

// Index.ets:L531-L558
@Builder
private MoodChip(idx: number) {
  Column({ space: 4 }) {
    Stack({ alignContent: Alignment.Center }) {
      Circle({
        width: idx === this.selectedMood ? 54 : 44,
        height: idx === this.selectedMood ? 54 : 44
      })
        .fill(idx === this.selectedMood ? this.moodColor(idx) : this.moodChipBg(idx))
        .opacity(idx === this.selectedMood ? 1.0 : 0.92)
        .transition(TransitionEffect.OPACITY.animation({ duration: 220, curve: Curve.EaseOut }))
      Text(this.MOOD_EMOJIS[idx])
        .fontSize(idx === this.selectedMood ? 30 : 24)
    }
    Text(this.MOOD_NAMES[idx])
      .fontSize(11)
      .fontWeight(idx === this.selectedMood ? FontWeight.Bold : FontWeight.Medium)
      .fontColor(idx === this.selectedMood ? this.moodColor(idx) : $r('app.color.text_secondary'))
  }
  .width(56)
  .height(82)
  .justifyContent(FlexAlign.Center)
  .onClick((): void => {
    animateTo({ duration: 220, curve: Curve.EaseOut }, (): void => {
      this.selectedMood = idx;
    });
  })
}

然后 7 个心情球通过 ForEach 铺开,keyGen 必须是 string(ArkTS 硬性要求,ForEach 第三参数 keyGen 返回必须是 string):

// Index.ets:L454-L461
Row({ space: 0 }) {
  ForEach(this.moodSelectKeys, (idx: number): void => {
    this.MoodChip(idx)
  }, (idx: number): string => idx.toString())
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)

7.1 总体结构:Stack 叠层 + 模糊彩球 + Scroll.Spring

页面最外层用 Stack,自底向上四层:

  1. bg_page 纯色背景(100% × 100%)
  2. 左上一团橙色模糊球(#FF7A50,opacity 0.1,blur 20,定位到屏幕外一角)
  3. 右下一团青色模糊球(#3FBFBF,同样 blur 20)
  4. Scroll 包裹 Header + MoodPickerCard + TrendCard,edgeEffect(EdgeEffect.Spring) 做弹簧回弹。

对应代码在 Index.ets:L776-L813

// Index.ets:L776-L813
build() {
  Stack({ alignContent: Alignment.TopStart }) {
    Column() { }
      .width('100%')
      .height('100%')
      .backgroundColor($r('app.color.bg_page'))

    Circle({ width: 240, height: 240 })
      .fill(this.SPOT_COLOR_A)
      .opacity(0.10)
      .blur(20)
      .position({ x: -80, y: -60 })

    Circle({ width: 280, height: 280 })
      .fill(this.SPOT_COLOR_B)
      .opacity(0.10)
      .blur(20)
      .position({ x: 110, y: 560 })

    Scroll() {
      Column({ space: 18 }) {
        this.HeaderBuilder()
        this.MoodPickerCard()
        this.TrendCard()
        Blank()
          .height(32)
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
    .padding({ top: 24, bottom: 18 })
    .edgeEffect(EdgeEffect.Spring)
  }
  .width('100%')
  .height('100%')
}

这种 Stack 的好处是:两团 blur 彩球是装饰层,跟 Scroll 完全不互相影响,也不会因为用户滚动而跟着动(视觉上更像固定的背景氛围)。卡片上都带阴影 shadow({ radius: 28, offsetY: 10 }),让视觉有层次感。

7.2 数据驱动:ForEach 的 keyGen 不要省略

7 天倒序列表 RecentListBuilder 也是靠 ForEach 渲染,offsetIdx: numberthis.last7Reverse = [6,5,4,3,2,1,0] 里取,然后 this.trendDays[offsetIdx] 拿到日期字符串,keyGen 用 'row_' + offsetIdx.toString()

// Index.ets:L672-L681
@Builder
private RecentListBuilder() {
  Column({ space: 10 }) {
    ForEach(this.last7Reverse, (offsetIdx: number): void => {
      this.RecentRow(this.trendDays[offsetIdx])
    }, (offsetIdx: number): string => 'row_' + offsetIdx.toString())
  }
  .width('100%')
  .margin({ top: 6 })
}

为什么要加前缀 row_ / wk_?因为 ArkUI 会把 ForEach 第三参数作为虚拟 DOM 节点的稳定标识,如果你写 idx.toString() 作为 key,但页面里有三个 ForEach(心情选择 + 星期行 + 列表行),它们都出 0,1,2...,理论上有 key 冲突隐患。加前缀能让每层 ForEach 的 key 天然隔离,调试也更好看。


八、构建层面最后一关:deviceTypes 双兼容(00303214 错误修复)

当 IDE 诊断板第一次清零后,我在 DevEco 点了 Run,结果 hvigor 输出了这么一段:

hvigor ERROR: Failed :entry:default@PreBuild...
hvigor ERROR: 00303214 Configuration Error
Error Message: The type of target device does not match the device type configured by module: entry.
Required device type:wearable, current module device type:phone

这条错误其实特别直白,但第一次碰到会懵——因为明明我们就是要写手机屏心情日记,为什么构建参数里要传 wearable?根因是:同一个工程最早是手表项目,DevEco Studio 里「运行配置 / Run Configurations」的 requiredDeviceType 还停留在 wearable 没改过来,于是构建命令就变成了:

hvigorw.js --mode module \
  -p module=entry@default \
  -p product=default \
  -p requiredDeviceType=wearable assembleHap ...

而我在为手机 App 做准备的时候,已经把 entry/src/main/module.json5deviceTypes 改成了:

// ❌ 只支持 phone,会拒绝 requiredDeviceType=wearable
"deviceTypes": [
  "phone"
]
8.1 修复策略:最小改动闭环

经验总结里强调过一个原则——一次只处理一个错误域(配置问题先只改配置,别同时动代码)。我把这条原则用在这里,方案是两步:

  1. 在 module.json5 里把 deviceTypes 改成 ["phone","wearable"],让两种 requiredDeviceType 都能通过;
  2. 不去手动修改 entry/build-profile.json5targets 字段追加 deviceTypes——经验 #1015379 特别提示,targets 里的 deviceTypes 字段在某些 DevEco 版本 schema 里是可选且不能随便加的,加错了反而会触发另一种「schema 不匹配」错误。因为 targets 默认会继承 module.json5 的 deviceTypes,所以这一步可以不做。

最终修复后的 [module.json5#L7-L10](file:///d:/HarmonyOSProject/shoubiao/entry/src/main/module.json5#L7-L10):

{
  "module": {
    "deviceTypes": [
      "phone",
      "wearable"
    ],
    ...
  }
}

改完以后,我再跑一次 GetDiagnostics()——返回 [],IDE 诊断仍为 0。接下来让用户在 DevEco 里重新跑同一条 hvigor assembleHap 命令即可。如果后续签名还报错(比如 profile 文件里没包含 wearable 设备类型),那就是另一个独立的「签名配置域」问题,和 ArkTS 代码、JSON 配置都无关了,需要进 File → Project Structure → Signing Configs 重新生成 profile。


九、最终交付清单 & 验收

最后回头对照一下这次的交付目标,基本全部达成:

验收项 状态 说明
7 档心情选择 + emoji + 名字 + 220ms animateTo MoodChip + ForEach(moodSelectKeys)
笔记 TextInput(placeholder 合法写法) 构造参数内传 placeholder,placeholderColor 链式
本地 Preferences 持久化 读:getPreferences + get + parseRecords;写:escapeJson + 手写 JSON + putSync + flushSync
14 天滚动窗口 + 当天覆盖 syncTodayStatecutKey = 今天-13天
保存 toast 反馈 promptAction.showToast,Resource message
7 天心情柱状图(渐变) TrendBar.linearGradient([浓色, soften(浓色,0.45)])
7 天倒序历史 + 今天高亮 RecentRow 三列布局,dateKey === todayStr 分支
顶部 Header:日期/星期/标题/记录数 HeaderBuilder,weekdayRes 类型保持 Resource
平均心情 emoji refreshAverage 取四舍五入索引
梦幻背景彩球 + Scroll.Spring Stack 四层 + edgeEffect(EdgeEffect.Spring)
深浅双主题资源 base/dark 两份 color.json;字符串已国际化可扩展
ArkTS 诊断 0 错误 GetDiagnostics() 返回 []
deviceTypes 双兼容(phone + wearable) module.json5 已加 wearable,两种 requiredDeviceType 可过

十、留给自己(和你)的 6 条 ArkTS 生存法则

把这次所有踩坑压成 6 条可以直接抄的操作准则,下次再从 0 写 HarmonyOS ArkTS 代码时,能少走 80% 的弯路:

  1. 先建 class,再写 @State 任何数组里放的结构都必须是 new SomeClass() 出来的实例,字段要带默认值,杜绝「对象字面量直接塞进数组」——ArkTS 会判无类型。
  2. 所有 $r() 都当 Resource 一路传到底, 除非你明确知道它是 Color 或者是 Float。想拼接字符串?别拼,拆成两根 Text 上下或左右排列。
  3. @Builder 里零代码: 只能出现组件声明、属性链、if/else 组件分支、ForEach。想写 let 的地方,一律写成类的私有纯方法hasRecordmoodOfbarHeightPx…),UI 层直接调。
  4. JSON.parse 之后三段式: as objectinstanceof Array 校验 → 每个字段写死名字 dict['xxx'] + typeof 判断,不要 for..in,不要 any,不要「先转 Map 再拿」。
  5. Preferences 写入用 putSync + flushSync 并用 try/catch 包住(catch 子句不要写类型标注!)。需要抛 toast 反馈就用 promptAction.showToast({ message: Resource, duration, bottom })
  6. deviceTypes 多目标兼容优先改 module.json5,不要先动 build-profile.json5:targets 配置问题一次只改配置文件,别和代码修改混在同一轮提交里,避免错误域叠加造成调试噩梦。

如果你读完这篇文章之后,也在做一个 ArkTS 心情日记或者类似的本地记录类 App,建议你把文中的 MoodRecord 数据模型、parseRecords / persist 两段、以及「@Builder 零 let 下沉纯方法」的模式直接拿去用——这样 27 条错误的那一幕就不会再在你的诊断板上重演。

祝开发顺利,每天都有好心情 🫶。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐