巡检人员填写一条设备记录,刚写完故障描述就切到了其他应用。回来时页面重新创建,输入框空了。这个场景很常见,但真正的技术问题并不是“输入框为什么没记住文字”,而是:业务记录究竟在什么时候拥有了可靠的持久化身份?如果数据只活在页面 @State 里,组件和应用进程的生命周期就成了业务保存的边界,这是一个不应该发生的耦合。

离线巡检不要求刚创建记录就能立即联网,它首先要求本地能保存、能重新加载、保存失败有反馈。因此第一步先建立一个独立的数据仓储,使用 ArkData 的关系型数据库存放草稿,页面只负责输入与展示,不直接拼 SQL。后续再扩展版本迁移、事务写入、重试与同步队列,不必推倒这一阶段的数据模型。

ArkData 离线巡检技术封面

把“保存按钮”拆成四个明确的责任

最容易写出的实现,是在按钮回调里直接 getRdbStore、建表、拼对象、写入数据,然后立即清空表单。它看似省事,但数据库初始化慢一点、写入失败一次,页面状态就可能与数据库脱节。更合适的边界分工如下:页面收集输入;仓储负责数据校验与落库;数据库负责持久性;查询结果负责恢复页面可见的草稿列表。

本系列的数据模型先保持紧凑:id 是数据库自增主键,title 是巡检对象的简短名称,body 是记录内容,updated_at 是更新时间的毫秒时间戳。title、body 明确不能为空。先不加入 sync_state、网络地址和附件字段,因为后续讨论离线同步时才需要它们。把未来需求提前写成十几个没有用的字段,只会让第一阶段更难验证。

字段数据库类型约束用途
idINTEGERPRIMARY KEY AUTOINCREMENT记录唯一标识
titleTEXTNOT NULL巡检对象名称
bodyTEXTNOT NULL记录内容
updated_atINTEGERNOT NULL本地更新时刻

数据初始化流程有三个必要步骤:以 UIAbility 上下文打开数据库;创建不存在的表;在数据结构可用后才允许查询或插入。数据库对象可以缓存,但不能在初始化之前就被视为可用。下图中的初始化、读写和结果恢复是三个不同阶段,正好对应实际故障排查时的三个入口。

离线巡检数据仓储模块关系图

仓储层代码:把数据库的生命周期藏在接口后面

建立 entry/src/main/ets/model/DraftRepository.ets。下面使用 @kit.ArkData 的 relationalStore,以及 @kit.AbilityKit 的应用上下文类型。示例采用 Stage 模型普通应用目录,数据保存在应用自身沙箱,不申请外部存储权限。使用 SecurityLevel.S1 只是本例配置,正式产品应根据巡检数据的敏感等级选择适当的安全级别和加密方案。

import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export interface DraftRecord {
  id: number;
  title: string;
  body: string;
  updatedAt: number;
}

const CONFIG: relationalStore.StoreConfig = {
  name: 'inspection_draft.db',
  securityLevel: relationalStore.SecurityLevel.S1
};

const CREATE_TABLE: string =
  'CREATE TABLE IF NOT EXISTS inspection_draft (' +
  'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
  'title TEXT NOT NULL, body TEXT NOT NULL, ' +
  'updated_at INTEGER NOT NULL)';

export class DraftRepository {
  private store: relationalStore.RdbStore | null = null;
  private opening: Promise<relationalStore.RdbStore> | null = null;

  private async ready(context: common.UIAbilityContext): Promise<relationalStore.RdbStore> {
    if (this.store !== null) {
      return this.store;
    }
    if (this.opening === null) {
      this.opening = (async (): Promise<relationalStore.RdbStore> => {
        const db = await relationalStore.getRdbStore(context, CONFIG);
        await db.executeSql(CREATE_TABLE);
        this.store = db;
        return db;
      })();
    }
    try {
      return await this.opening;
    } finally {
      this.opening = null;
    }
  }

  async add(context: common.UIAbilityContext,
    title: string, body: string): Promise<number> {
    const cleanTitle: string = title.trim();
    const cleanBody: string = body.trim();
    if (cleanTitle.length === 0 || cleanBody.length === 0) {
      throw new Error('标题和内容都不能为空');
    }
    const db = await this.ready(context);
    const values: relationalStore.ValuesBucket = {
      title: cleanTitle,
      body: cleanBody,
      updated_at: Date.now()
    };
    return await db.insert('inspection_draft', values);
  }

  async all(context: common.UIAbilityContext): Promise<DraftRecord[]> {
    const db = await this.ready(context);
    const predicate = new relationalStore.RdbPredicates('inspection_draft');
    const cursor = await db.query(predicate,
      ['id', 'title', 'body', 'updated_at']);
    const items: DraftRecord[] = [];
    try {
      const idCol = cursor.getColumnIndex('id');
      const titleCol = cursor.getColumnIndex('title');
      const bodyCol = cursor.getColumnIndex('body');
      const timeCol = cursor.getColumnIndex('updated_at');
      while (cursor.goToNextRow()) {
        items.push({
          id: cursor.getLong(idCol),
          title: cursor.getString(titleCol),
          body: cursor.getString(bodyCol),
          updatedAt: cursor.getLong(timeCol)
        });
      }
    } finally {
      cursor.close();
    }
    return items.sort((a: DraftRecord, b: DraftRecord) => b.updatedAt - a.updatedAt);
  }
}

代码的重点不是语法数量,而是两个边界。首先,ready() 用 opening 保存进行中的初始化 Promise:页面的 onAppear 与用户点击可能同时请求仓储,不能因为第一个初始化尚未完成就让第二个请求又创建一次独立流程。失败时清掉 opening,下次操作可以重新尝试;只有建表成功才把实例赋给 store。其次,查询得到的 ResultSet 必须关闭。finally 不论游标是否正常迭代完毕都会执行,避免忘记释放资源。

需要特别注意:updated_at 只是按时间排序的辅助字段,并不能自动解决多设备并发写入;id 是本地数据库内部标识,也不能直接作为未来云同步的全局业务 ID。把这两个概念分开,后续做同步去重和冲突处理才不会走进死胡同。

页面接入:只有落库成功,才能把“保存完成”告诉用户

接着在 entry/src/main/ets/pages/Index.ets 接入仓储。页面保存状态 saving 用于阻止按钮连点,message 用于向用户反馈具体处理结果;加载失败时仍保留输入内容,不因数据库异常而重置表单。以下页面代码与上面的仓储文件配套使用。

import { common } from '@kit.AbilityKit';
import { DraftRepository, DraftRecord } from '../model/DraftRepository';

@Entry
@Component
struct Index {
  private repository: DraftRepository = new DraftRepository();
  @State private title: string = '';
  @State private body: string = '';
  @State private saving: boolean = false;
  @State private message: string = '';
  @State private drafts: DraftRecord[] = [];

  private context(): common.UIAbilityContext {
    return getContext(this) as common.UIAbilityContext;
  }

  aboutToAppear(): void {
    this.refresh();
  }

  private async refresh(): Promise<void> {
    try {
      this.drafts = await this.repository.all(this.context());
    } catch (err) {
      this.message = '草稿列表加载失败,请检查本地数据访问状态';
    }
  }

  private async save(): Promise<void> {
    if (this.saving) {
      return;
    }
    this.saving = true;
    this.message = '';
    try {
      await this.repository.add(this.context(), this.title, this.body);
      this.title = '';
      this.body = '';
      await this.refresh();
      this.message = '草稿已保存到本地';
    } catch (err) {
      this.message = '保存失败:请检查必填项或数据库状态';
    } finally {
      this.saving = false;
    }
  }

  build() {
    Column({ space: 12 }) {
      Text('离线巡检记录').fontSize(27).fontWeight(FontWeight.Bold)
      TextInput({ text: this.title, placeholder: '设备或巡检点名称' })
        .onChange((value: string) => { this.title = value; })
      TextArea({ text: this.body, placeholder: '填写本次巡检情况' })
        .height(130)
        .onChange((value: string) => { this.body = value; })
      Button(this.saving ? '保存中…' : '保存本地草稿')
        .enabled(!this.saving)
        .onClick(() => { this.save(); })
      Text(this.message).fontColor('#2563EB').fontSize(13)
      Text('本地已保存的记录').fontSize(18).fontWeight(FontWeight.Medium)
      List({ space: 8 }) {
        ForEach(this.drafts, (item: DraftRecord) => {
          ListItem() {
            Column({ space: 4 }) {
              Text(item.title).fontSize(16)
              Text(item.body).fontSize(13).maxLines(2)
            }
            .padding(12).alignItems(HorizontalAlign.Start)
            .backgroundColor('#F1F5F9').borderRadius(10)
          }
        }, (item: DraftRecord) => item.id.toString())
      }
      .layoutWeight(1)
    }
    .width('100%').height('100%').padding(16)
    .alignItems(HorizontalAlign.Start)
  }
}

这个页面刻意没有承诺“输入一半就永不丢失”。当前保存策略是显式点击保存后持久化;如果需要键入时自动暂存,应另行实现防抖、焦点退出保存或恢复机制。不能把数据库已经具备持久能力,直接等同于所有尚未提交的输入都已持久化。

从输入校验到本地草稿列表的保存链路

用可复现步骤检查存储,而不是凭界面想当然

第一步,打开页面输入完整标题与正文,点击保存,确认界面提示、列表显示的标题与文字对应。第二步,关闭页面再重新进入,确认加载路径能重新查询到同一记录。第三步,只填标题不填正文,确认仓储抛出校验错误,输入框仍保持原始内容。第四步,连续快速点击保存按钮,确认 saving 可以避免同一页面内重复提交。第五步,针对数据库打开失败或磁盘空间不足等异常,检查页面不会将失败操作显示为成功。

这套步骤验证的是业务约定,并不等于提供了一份已经采集的真实测试记录。真实工程中应配合 DevEco Studio 的 HiLog、数据库文件及设备运行过程记录环境、输入、预期与实际结果;出现问题时先分清“数据库打不开”“建表失败”“插入失败”“列表没刷新”究竟是哪一层,而不是只给按钮加一个 try/catch 便认为错误处理完成。

还要注意数据安全边界:应用私有关系型数据库与用户主动导出的文件不是同一种数据资产。如果巡检记录可能含个人信息、设备安全信息或商业数据,就需要按业务规则设置安全级别、加密、保留期限及清除策略。示例中的简单 S1 配置不能替代正式安全设计。

这一阶段为什么先选 RDB

如果只有一个“是否开启深色主题”的布尔开关,轻量键值存储会比关系型数据库更直接;而巡检草稿具有多条记录、可查询、可排序、以后要按状态筛选和批量更新,RDB 的表结构、谓词查询、事务能力更适合作为长期基础。代价是需要管理建表、版本升级、资源释放与异常路径。把这些职责集中在 DraftRepository,后续新增业务能力时页面可以保持相对简单。

目前已经形成明确的本地数据生命周期:初始化数据库—输入校验—单条写入—重新查询—展示草稿。下一篇会处理最容易被忽视的工程问题:已有用户数据库从旧表结构升级到新结构时,怎样保证字段迁移有序进行、失败时不误标版本,并为后面的批量事务打下基础。

工程接入时还应把“草稿已保存”理解为一个明确的业务事件,而不是对按钮点击的即时回应。按钮触发后必须等待 insert 返回,接着再尝试刷新列表;若列表刷新失败,应在反馈中区分“写入已成功、显示更新失败”和“写入失败”,避免让用户因误判而反复提交。本示例仍使用简化的提示文字,正式项目可用独立的保存结果状态来分别表达这两种情形,并在后续完善幂等写入策略。

此外,页面的 aboutToAppear 调用加载方法,意味着重新进入页面时会根据数据库重新构造列表,而不是寄希望于上一轮组件状态仍然保留。若业务还要求恢复未提交的输入内容,就应将表单临时快照设计为另一个独立机制,避免与已经提交的数据库草稿混为一谈。可恢复性是一个由保存时机、数据约束、异常反馈和重建流程共同决定的工程属性。

参考资料:

Logo

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

更多推荐