出行票夹最容易被低估的一处交互,不是新增票据,而是“搜索以后打开详情,再返回列表”。设想用户在 TravelDeck 中输入“上海”,打开高铁票的详情,随后清除关键词并旋转屏幕。如果列表原本只依靠数组下标记住选择项,就可能把高亮留在另一张票上;如果详情直接引用被筛选列表的对象,数据重新加载后还可能显示旧内容。两种错误在三条静态样例里很隐蔽,但数据库接入之后会不断出现。

前两节已经完成两项基础:第一节用 GridRow/GridCol 让同一份票据在窄屏和宽屏自动排成一、二、三列;第二节通过 TicketRepository 把 TravelTicket 存进 RDB v2,表字段是 id/category/title/code/time/location/status。这一节不重复造表,也不新增一套票面模型,只解决查询条件、详情归属和异步回退:搜索时读数据库,点选时保存稳定的 id,详情页根据 id 重新取得当前记录,返回后仍遵循原先的响应式布局。

TravelDeck 条件检索与稳定详情的技术封面

一、为什么筛选视图绝不能等同于数据本体

在这个工程里,三条示例票的业务身份仍是 rail-01、air-01 和 visit-01;高铁 G105 的出发地为北京南、目的地上海虹桥,时间写作“11月18日 09:10”;航班 CZ3552 的展示标题为“上海虹桥 → 深圳宝安”,预约票标题为“海边艺术馆”。这些内容来自已存在的本地示例,并非真实订单,更不能用票面编号去推测个人身份。用户看到的“全部”“高铁”“航班”“预约”分类只是查询条件,不能改变数据库里票据原有的 category 字段。

系统应同时区分四种状态。事实状态由 TicketRepository 负责,代表当前本地库中的行;查询状态包含关键词和类别;展示状态为本轮查询产生的数组;选择状态只有一个 selectedId。选择某张票不是把选中位置记成 2,而是把它的业务 id 记为 air-01。这样即使屏幕从一列变为三列,或者查询结果由三条减少到一条,组件身份都不会变化。

查询结果不能成为直接写库的入口。比如用户把状态切成“待使用”,点击当前搜索结果中的一张票,详情读取和后续更新都应凭主键处理;不应假设筛选列表中的第 0 项也是原始数组中的第 0 项。页面返回时再发起查询,才能拿到最新内容。这种重新读取不是浪费,而是定义了一个清楚的同步边界:列表缓存可丢弃,业务 id 不丢弃。

二、复用 RDB v2:让谓词表达筛选,让 ResultSet 负责取行

仓储仍位于 entry/src/main/ets/data/TicketRepository.ets。既有 open(ctx) 负责打开 travel_deck.db 和验证版本;本节只在仓储类里增加 searchTickets() 与 findById() 两个方法。@kit.ArkData 的 relationalStore.RdbPredicates 支持 equalTo、contains、orderByAsc 和 limitAs,可以避免把关键词拼接成一段 SQL。这里不引入网络请求,不需要额外 INTERNET 权限;应用沙箱数据库仍受本应用隔离边界约束。

// TicketRepository.ets:接在上一节已经 open() 的仓储类中
import { relationalStore } from '@kit.ArkData';
import { TravelTicket } from '../model/TravelTicket';

const TICKET_COLUMNS: string[] = [
  'id', 'category', 'title', 'code', 'time', 'location', 'status'
];

async searchTickets(keyword: string, category: string): Promise<TravelTicket[]> {
  if (!this.store) { throw new Error('请先打开票据数据库'); }
  const predicates = new relationalStore.RdbPredicates('tickets');
  if (category !== '全部') { predicates.equalTo('category', category); }
  const queryText: string = keyword.trim();
  if (queryText.length > 0) {
    predicates.contains('title', queryText);
  }
  predicates.orderByAsc('id').limitAs(20);
  const rs = await this.store.query(predicates, TICKET_COLUMNS);
  const rows: TravelTicket[] = [];
  try {
    while (rs.goToNextRow()) {
      const read = (col: string): string =>
        rs.getString(rs.getColumnIndex(col));
      rows.push(new TravelTicket(read('id'), read('category'),
        read('title'), read('code'), read('time'),
        read('location'), read('status')));
    }
  } finally { rs.close(); }
  return rows;
}

为了使查询示例保持可复制,代码中同时列出了字段常量与方法的依赖;完整工程需把常量置于类外,方法放入已有 TicketRepository,不要在类里重复声明 store 或 open。其中 limitAs(20) 是单次展示上限,不是“数据库只能存二十张票”。将来数据多于二十条时,要设计有稳定排序的分页,且 offsetAs 必须配合 limitAs;不能把“当前列表只有二十条”误认为查询已经结束。按 id 排序是为了保证相同数据的展示次序稳定,不代表票据按照出发时间排序。

这里还有一个容易忽略的历史包袱:第二节的 time 字段存的是“11月18日 09:10”这样的显示字符串,没有年份和统一时区。若直接写 ORDER BY time,跨月、跨年会出现看似排序成功、实则时间顺序错误的问题。因此当前先使用稳定 id 排序,下一次进行数据库结构升级时再单独加入可比较的时间戳或标准化 ISO 时间字段,并明确时区来源。不能为了本节的示例默默把旧行变成不存在的日期。

完整详情获取方法不需要先拿全部票据再在内存里找,它只读取指定主键,避免列表搜索条件影响详情展示:

async findById(id: string): Promise<TravelTicket | undefined> {
  if (!this.store) { throw new Error('请先打开票据数据库'); }
  const predicates = new relationalStore.RdbPredicates('tickets');
  predicates.equalTo('id', id).limitAs(1);
  const rs = await this.store.query(predicates, TICKET_COLUMNS);
  try {
    if (!rs.goToNextRow()) { return undefined; }
    const read = (col: string): string =>
      rs.getString(rs.getColumnIndex(col));
    return new TravelTicket(read('id'), read('category'),
      read('title'), read('code'), read('time'),
      read('location'), read('status'));
  } finally { rs.close(); }
}

当详情目标已经删除,方法明确返回 undefined,由页面显示“票据已不存在”,而不是捕获异常后随便展示下一条。在 finally 中关闭 ResultSet 同样重要:正常读取和中途读取字段失败,都不会留下未关闭的游标。涉及主键和检索的边界,数据库已经提供了比全量内存扫描更清楚的约束。

DevEco Studio 白色主题:TicketRepository 条件查询与票夹预览

三、查询页面保留原先的 GridRow,异步更新需要“只认最后一次”

打开 TicketHomePage.ets,保留第一节的 TicketCard 与 GridRow,把原来的静态 tickets 初始化换成 @State private tickets: TravelTicket[] = []。页面另外管理 keyword、category、selectedId、loading 和 errorText。这几个字段属于当前 UI 会话,不写回 RDB。用户输入搜索字时可以直接调用查询,但密集输入会形成一串异步请求,如果前一个慢请求在最后返回,就会把新结果覆盖成旧结果。

@State private keyword: string = '';
@State private category: string = '全部';
@State private tickets: TravelTicket[] = [];
@State private selectedId: string = '';
@State private loading: boolean = false;
@State private errorText: string = '';
private requestSerial: number = 0;

private async reload(): Promise<void> {
  const serial: number = ++this.requestSerial;
  this.loading = true;
  this.errorText = '';
  try {
    const rows = await this.repository.searchTickets(
      this.keyword, this.category);
    if (serial !== this.requestSerial) { return; }
    this.tickets = [...rows];
    if (!rows.some((t: TravelTicket) => t.id === this.selectedId)) {
      this.selectedId = rows.length > 0 ? rows[0].id : '';
    }
  } catch (e) {
    if (serial === this.requestSerial) {
      this.errorText = '查询未完成,请重试';
    }
  } finally {
    if (serial === this.requestSerial) { this.loading = false; }
  }
}

这里 requestSerial 不表示数据库事务,也不能替代取消查询;它只保证 UI 不采纳过期结果。更新关键词使用 this.keyword = value; this.reload(),切换分类同样如此。首屏 aboutToAppear 中必须先 await repository.open(getContext(this) as common.UIAbilityContext),成功后再 reload();这是 C-02 既有的 open(),不应在每次键盘输入时重复打开数据库。

视图采用一条搜索框与一行分类按钮,下面继续放原有 GridRow:

TextInput({ placeholder: '搜索票面标题', text: this.keyword })
  .onChange((value: string) => {
    this.keyword = value;
    this.reload();
  })
Row({ space: 8 }) {
  ForEach(['全部', '高铁', '航班', '预约'], (value: string) => {
    Button(value)
      .backgroundColor(value === this.category ? '#2264BF' : '#E8EEF6')
      .fontColor(value === this.category ? Color.White : '#475569')
      .onClick(() => { this.category = value; this.reload(); })
  }, (value: string) => value)
}
// 此后保留原篇 GridRow + GridCol + TicketCard 代码

GridRow 的 ForEach key 继续使用 ticket.id。详细视图可用当前页下方的详情卡,也可以使用路由进入独立页面;如果采用独立页面,路由参数只传 id,详情出现时通过 findById(id) 重新取记录。不要传整个可变票据对象当作永久真相。对数据库读取失败、主键不存在和加载中分别展示不同提示,确保“空搜索结果”和“数据库坏了”不会被一个空白屏幕掩盖。

四、验证不只看搜索成功,还要测试五种回退路径

示例时先把三条示例记录加入数据库。输入“上海”,预期可以看到标题含“上海”的高铁和航班;切到“预约”,预期列表为空。再清除关键词,选择“预约”,应只出现海边艺术馆。实际页面数量取决于本地已经保存的数据,如果用户自行删除或修改过示例票,则应该按真实数据库结果显示,而不是强制拼回三张默认卡。这也是区分 demo 和业务工程的第一条原则。

第二个用例是快速输入“北”“北京”“北京南”,用日志里的请求序号辅助判断最终 tickets 是否来自最后一次查询。第三个用例是先选择 air-01,再调整窗口宽度,页面的 1/2/3 列布局变化不应改变选中业务 id。第四个用例是打开详情时,构造该 id 被其他页面删除,返回时应读出“该票据已不存在”,而不能回退成数组下标相同的另一张。第五个用例是让查询故意失败,检查是否留下“查询未完成,请重试”的提示,重新加载是否能够恢复。

搜索、详情与删除冲突的状态处理关系图

这些验证并没有要求在开发图里捏造“编译成功”或 SQL 耗时数据;DevEco 的 HiLog 可以记录脱敏的查询序号、类别和结果条数,不能输出真实乘车人姓名、证件号、二维码或完整行程。筛选与详情读取都通过用户已有的数据库操作执行,不需要云端账号,也不存在跨设备自动同步。用户想在另一台设备找到同一张票,仍需要后续单独设计同步协议和隐私授权。

五、一次小改动给下一节留下了什么

这一节没有改变 TravelTicket 的七个业务字段,却明确了三个长期可复用的约束:查询是可丢弃的视图,详情以稳定 id 重新读取,旧异步请求不得覆盖新 UI。对折叠屏来说,真正有价值的是窗口宽度切换与业务状态解耦;一列变三列可以重新布局,但不能让用户“选中的票”换成另一张。在状态异常时,界面也应清楚地区分“没有匹配记录”“票据已删除”和“查询失败”。

还有一项不能用截图代替的验收,是恢复路径的重复性。测试人员可以依次执行“打开票夹→输入关键词→进入详情→从另一入口删除记录→返回→重新搜索”,检查页面里是否仍存在已删除的票面。如果只测试刚安装时的三条样例,那么数据库读写、页面缓存和筛选状态刚好都一致,很多竞态根本不会显露。还应在数据库为空的情况下分别验证“全部”与“高铁”分类,确保空态文案、重试按钮和选中 id 的清理一致。对于长关键词,contains 的匹配长度有限制,输入框应主动约束长度并在本地提示,而不是把一个无法执行的查询交给仓储层。

第四节将结束 TravelDeck 连载,重点放在大字体、屏幕阅读顺序、多窗口回归与可见状态提示,并补齐测试矩阵。届时也要评估 time 字段的标准化升级与票据隐私边界,但不应在没有真实票务平台接入的情况下声称支持官方验票或跨设备同步。

参考文档:华为 RdbPredicates 接口、RdbStore 查询方法、关系型数据库持久化、GridRow 响应式栅格布局。

Logo

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

更多推荐