PageShelf 的最后一次工程评审,没有再讨论书卡是不是足够漂亮。问题发生在一名图书管理员的日常操作中:他先勾选“只看待处理”,再把平板窗口缩成窄屏,书架只剩两本书;随后其中一本被借走,另一本被删除。页面若仍保留上一轮的滚动偏移和当前详情 ID,就可能出现看似有内容、实际上卡片为空,或点击返回后落在一段空白里的情况。对于一套日常使用的借阅工作台,空态不是四本示例数据之外的小事,而是状态模型是否闭环的验收点。

前三篇依次解决了响应式栅格、Navigation 详情路由,以及窗口变化后的筛选与滚动上下文。本篇仍沿用同一 PageShelf 工程,数据模型为 ShelfBook { id, title, section, borrowed },四条种子记录编号 bk-01 至 bk-04;showPendingOnly 表示仅显示未借出的待处理图书,BookRoute.bookId 为详情页的稳定路由参数,savedOffsetVp 与 savedFilter 保存上次滚动上下文。断点仍为 360/600/840vp,GridRow 列数仍为 4/4/8/12。我们不把最后一篇变成另一套项目,而是把前面可以验证的功能,整理成能够被明确验证和扩展的工程结构。

PageShelf 从多端书架到可验收工程的技术封面

一、先划清三种“没有书”的含义

产品同学经常把“列表空了”当成一个状态,代码也顺手写成 if (books.length === 0)。这会漏掉两个截然不同的问题。第一种是整个数据源尚未完成加载,此时 UI 应展示加载反馈,而不能提早提示没有图书。第二种是数据已加载且确实没有任何记录,应该有明确的“暂无图书”提示。第三种是总数据非空,但用户当前启用了“只看待处理”,过滤后为空;这个时候若写成“暂无图书”,用户可能以为原来的记录被删除了。

所以必须先决定页面状态,再决定显示几张卡片。最小的状态组合包含 loading、errorMessage、allBooks、showPendingOnly 四项。visibleBooks 是从前两者的稳定成功状态中派生的结果,而不是第二份需要手动同步的书籍数组。状态判定的优先级是:正在加载、加载失败、所有数据为空、筛选结果为空、正常展示。将“筛选无结果”与“全库无数据”分开,会改变恢复按钮、刷新入口和错误提示的含义。

以下代码属于 entry/src/main/ets/pages/Index.ets 的业务状态部分。为便于回归,仍提供四条本地种子数据;真正接入网络时,把初始化来源换成仓储返回值即可,不能以静态数组伪装已联网读取。

interface ShelfBook {
  id: string;
  title: string;
  section: string;
  borrowed: boolean;
}

const seedBooks: ShelfBook[] = [
  { id: 'bk-01', title: '分布式系统笔记', section: '计算机', borrowed: false },
  { id: 'bk-02', title: '类型与边界', section: '工程', borrowed: true },
  { id: 'bk-03', title: '算法的日常', section: '计算机', borrowed: false },
  { id: 'bk-04', title: '设计中的留白', section: '设计', borrowed: true }
];

// 合并进前三篇已有的 @Entry @Component struct PageShelf
@State private allBooks: ShelfBook[] = seedBooks;
@State private loading: boolean = false;
@State private errorMessage: string = '';
@State private showPendingOnly: boolean = false;

private get visibleBooks(): ShelfBook[] {
  return this.showPendingOnly
    ? this.allBooks.filter((book: ShelfBook) => !book.borrowed)
    : this.allBooks;
}

private get emptyKind(): string {
  if (this.loading) return 'loading';
  if (this.errorMessage.length > 0) return 'error';
  if (this.allBooks.length === 0) return 'no-data';
  if (this.visibleBooks.length === 0) return 'filtered';
  return 'ready';
}

emptyKind 不是 HarmonyOS 提供的系统枚举,它是 PageShelf 内部的轻量决策结果。返回 filtered 时,页面仍可提供“显示全部”操作;返回 error 时,优先修复数据加载,而不是无条件清空筛选。测试时要主动制造这四种状态,不能仅看默认四本书能否正常排版。

二、在原有 Navigation 中增加空态,不再创建第二个页面栈

前一篇的 Navigation(this.pathStack) { this.shelfBody() }、BookRoute.bookId 和 .navDestination(...) 继续保留。新的变化只发生在书架主体;如果每个空态各创建一个 Navigation,返回栈可能被新实例覆盖,原来的筛选和详情就失去连续性。这里采用单一入口、分支渲染,而不是动态拼接一堆页面。

@Builder
private shelfBody() {
  Column({ space: 14 }) {
    Row() {
      Text('多端书架').fontSize(26).fontWeight(FontWeight.Bold)
      Blank()
      Button(this.showPendingOnly ? '显示全部' : '只看待处理')
        .onClick(() => { this.showPendingOnly = !this.showPendingOnly; })
    }
    Text(this.showPendingOnly ? '待处理 · ' + this.visibleBooks.length + '本'
      : '全部图书 · ' + this.visibleBooks.length + '本')
      .fontSize(14).fontColor('#64748B')
    if (this.emptyKind === 'ready') {
      Scroll(this.scroller) {
        GridRow({
          columns: { xs: 4, sm: 4, md: 8, lg: 12 },
          gutter: { x: 12, y: 14 },
          breakpoints: { value: ['360vp', '600vp', '840vp'],
            reference: BreakpointsReference.WindowSize }
        }) {
          ForEach(this.visibleBooks, (book: ShelfBook) => {
            GridCol({ span: { xs: 4, sm: 4, md: 4, lg: 3 } }) {
              this.bookCard(book)
            }
          }, (book: ShelfBook) => book.id)
        }
      }
      .layoutWeight(1)
      .onScrollStop(() => this.rememberScroll())
      Button('回到上次位置').onClick(() => this.restoreScroll())
    } else {
      this.emptyBody()
    }
  }
  .width('100%').height('100%').padding(18)
}

其中 bookCard、scroller、rememberScroll 与 restoreScroll 使用上篇实现,不需要重新造一份。注意 GridCol 的 span 与 GridRow 的 columns 必须同一套断点口径:小窗口每卡占四列,md 时两卡并排,lg 时四卡并排。断点是容器展示策略,不应由“是否借出”反过来改变。页面在空态时不渲染 Scroll,于是旧控制器不具备可滚动内容;从空态回到正常态以后,也不能立即无条件触发 scrollTo。

空态文案可简化为一个 @Builder,但“出错”必须留给后续真实的数据仓储提供重试回调。本地种子版本并没有网络 API,所以我们让错误页面仅显示诊断信息和“返回全部”,不编一个假的“云端加载成功”按钮。

@Builder
private emptyBody() {
  Column({ space: 12 }) {
    Text(this.emptyKind === 'loading' ? '正在准备书架…' :
      this.emptyKind === 'error' ? '书架暂时无法显示' :
      this.emptyKind === 'filtered' ? '当前没有待处理图书' :
      '书架还没有图书')
      .fontSize(19).fontWeight(FontWeight.Medium)
    Text(this.emptyKind === 'error' ? this.errorMessage :
      '你可以切换筛选条件,继续查看已有记录。')
      .fontSize(13).fontColor('#6B7280')
    if (this.emptyKind === 'filtered') {
      Button('显示全部').onClick(() => { this.showPendingOnly = false; })
    }
  }
  .justifyContent(FlexAlign.Center).width('100%').layoutWeight(1)
}

PageShelf 窄屏书架与筛选状态的9:16应用效果设计

三、把滚动恢复从“记住偏移”改成“满足前提才执行”

第三篇保存了 savedOffsetVp 和 savedFilter。正式验收时,我们要加一条保护:恢复动作必须同时满足列表可见、过滤条件一致、目标内容仍然存在。Scroller 只能尝试恢复物理距离,不能保证同一距离仍指向同一本书。对于四条种子记录,我们暂时保留“用户主动点击恢复”的交互;千万别把 onAreaChange 写成触发器,在每次窗口宽度改变时反复跳转。

private restoreScroll(): void {
  if (this.emptyKind !== 'ready') return;
  if (this.savedFilter !== this.showPendingOnly) return;
  if (!Number.isFinite(this.savedOffsetVp) || this.savedOffsetVp < 0) return;
  this.scroller.scrollTo({ xOffset: 0, yOffset: this.savedOffsetVp });
}

这段验证只能减少错误恢复,并不能把索引转成语义锚点。将来书库超过数百条、需要服务器分页、用户会在不同设备间同步阅读进度时,更推荐定义 anchorBookId、列表排序版本及分页游标。窗口发生重排后,先核对锚点仍存在于当前筛选集合,再通过可见索引定位;若锚点已消失,应平稳退回顶部或当前列表首项。这里不使用 index × 估算卡片高度,因为书名字数、字体缩放和列数都能改变实际高度。

Navigation 详情也要做一致性检查:从 BookRoute.bookId 查询图书,若仓储返回空值,就展示“图书已不存在”的详情空态并提供正常返回,不得回退到当前数组索引对应的其他图书。这一约束比炫目的转场更重要——路线可失效,用户的操作对象不能被悄悄替换。

四、用一张回归矩阵结束连载,而不是用一句“适配完成”

回归应从几个容易互相干扰的维度组合起来。第一组是宽度:在 359、360、599、600、839、840vp 附近分别检查单列、双列、四列卡片是否溢出,按钮是否仍可触达。第二组是状态:全部四条、仅两条待处理、全部借出导致筛选无结果、数据源为空、加载失败,各自应出现预期文字。第三组是导航:打开 bk-03,返回后 showPendingOnly 不应被重设,删除该 ID 时详情不应自动指向 bk-04。第四组是滚动:同筛选可手动请求恢复,不同筛选不可套用旧偏移。最后补做字体放大、横竖屏切换和小窗口返回手势,特别检查内容是否被底部操作区遮住。

多端书架空态、窗口断点与导航恢复的回归矩阵

这些是明确的待执行验证用例,不是已经跑出的性能成绩。性能方案也要按数据规模划界:四本静态书用 GridRow + ForEach 足以解释响应式布局;大量动态数据则应比较 List、Grid 配合 LazyForEach 或 Repeat 的节点创建与缓存行为。华为文档明确描述了 List 和懒加载组件的配合,但“换成 LazyForEach 就一定快”并不成立,数据源通知、稳定键、固定可见区域和嵌套滚动都可能影响效果。迁移前先以真实设备采集数据,不能给示例工程贴上不存在的帧率结论。

无障碍与数据可解释性也应进入验收清单,而不是在视觉设计阶段被遗漏。列表卡片的点击区域要覆盖标题与明确的操作控件,不应让用户只能点击一张很小的图封面;开启大字显示后,长标题应允许换行,操作按钮仍可见。加载反馈需要有可理解的语义,错误文案尽量解释用户下一步能做什么,而不是只展示错误码。对“只看待处理”这种筛选开关,应确认当前选中状态能被读屏或清晰的文字反馈识别。页面在不同设备上重排时,键盘焦点与视觉阅读顺序都需要复核,不能只以卡片的排列数量作为合格条件。

PageShelf 四篇连载最终留下的是一条可维护的边界:稳定 id 负责对象身份,showPendingOnly 负责业务过滤,NavPathStack 负责页面层级,GridRow 负责横向布局,Scroller 只在满足上下文前提时处理可视偏移,空态判断优先于绘制列表。这套约束比某个华丽屏幕更容易被未来的新同事复用。真正上线前仍需接入数据仓储、错误重试、无障碍描述与完整的设备回归,这些工作应作为独立验收项,而非假装已经包含在四本书的静态示例里。

参考官方文档:GridRow 栅格容器、GridCol 栅格子项、Navigation 与 NavPathStack、List 列表、LazyForEach。以上结构以 Stage 模型的 ArkUI 接口为基础,版本迁移时应核对所用 SDK 的断点继承规则。

Logo

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

更多推荐