在阅读工作台里,窗口变窄并不意味着读者改变了阅读对象。平板从横向双栏切为纵向单栏、折叠屏进入较小窗口,布局会重新计算,但读者刚刚看到的段落仍应留在视线附近。如果仅保存 Scroller 的像素偏移,文字换行之后,同一偏移可能落到完全不同的句子。对于有图片、标题和长段落的文章,这种偏差会随着内容高度变化逐渐放大。

阅读工作台已经完成目录与详情路由衔接、重复选择去重、返回后目录高亮同步。三条文章记录继续使用 canyon(峡谷行记)、metro(城市光谱)、cloud(云端书签)作为稳定身份,详情页仍为 ReaderDetail。这一次只处理阅读位置,不改动路由栈的业务含义:把位置记录为文章、内容块与块内进度的组合,然后在布局稳定后恢复到同一个内容区域。

窗口变化时的阅读锚点

一、为何不把滚动像素当成长期位置

同一段文字在宽屏可能占四行,在窄屏变成七行。若将宽屏的纵向偏移值原样传给窄屏,新的排版高度已经不同,滚动结果自然会向前或向后偏移。Scroller 擅长控制当前界面的滚动,不适合作为跨尺寸、跨内容版本的持久身份标识。这两种职责应该分开:排版层使用偏移量完成当前动作,业务层使用稳定段落身份保存继续阅读的位置。

目录身份、路由身份与锚点身份也不能混为一谈。selectedId 表示正在查看哪篇文章;NavPathStack 保存读者经过哪些详情页面;锚点中的 blockId 则表示该篇文章内部哪一块内容。即使“城市光谱”的详情仍在栈顶,布局发生变化后,内容块的位置依然可能重算。文章身份不变不等于屏幕坐标不变,因此锚点必须独立维护。

本文采用 articleId、blockId 和 progress 三个字段。articleId 使用已有稳定主键,blockId 由内容准备阶段为各段落赋予稳定名称,progress 表达读者在该内容块内的大致进度,取值在零和一之间。不要把当前列表下标作为块标识,因为编辑者在前面插入新段落时,后续下标都会移动。也不要直接使用正文前几个汉字作为唯一键:同一句模板文字可能出现在多个区块中。

二、将尺寸变化与路由切换分开处理

Navigation 的自适应模式可以随着可用空间选择单栏或分栏展示。分栏里的导航栏与 NavDestination 仍由同一 NavPathStack 管理,布局变化本身不应触发一轮额外的 pushPath。如果在 onAreaChange 回调里反复打开详情,容易把尺寸测量转化成重复路由,前面建立的去重规则也会失去价值。

因此页面只记录实际宽度,阅读位置由独立函数保存。宽度仅用于判断是否需要重新布局和安排恢复时机,不进入锚点主键。下面保留阅读工作台原有的 Navigation 结构,通过通用区域变化回调接收宽度;恢复动作由内容区完成,不在测量回调里直接寻找滚动控件。

@Entry
@Component
struct ReaderWorkbench {
  private pathStack: NavPathStack = new NavPathStack();
  @State widthVp: number = 0;

  @Builder
  readerPage(name: string, param: Object) {
    NavDestination() {
      Text('阅读详情')
    }
  }

  build() {
    Column() {
      Navigation(this.pathStack) {
        Text('文章目录')
      }
      .mode(NavigationMode.Auto)
      .navDestination(this.readerPage)
      .width('100%')
      .height('100%')
    }
    .onAreaChange((oldValue: Area, newValue: Area) => {
      const width: number = Number(newValue.width);
      if (Number.isFinite(width) && width > 0) {
        this.widthVp = width;
      }
    })
  }
}

这里的 widthVp 属于布局观察状态,不参与 ReaderDetail 的业务路由参数。NavigationMode.Auto 让导航容器负责单栏与分栏之间的适配,而阅读位置恢复由文章内容控制器负责。具体工程还需要把前面的目录组件与详情构建器接回相同位置;这段代码突出尺寸消息与路由管理的接口,不让两个模块相互修改对方内部状态。使用区域变化事件时要避免在回调中立即修改同一容器的宽度,否则测量和布局可能形成循环。

三、为段落建立可长期使用的身份

示例内容以“城市光谱”为当前文章,正文块依次命名 lead、route、summary。读者停在 route 内约百分之四十二的位置。记录这个比例不是要求滚动精确到同一像素,而是为字体大小变化留下可解释的恢复目标。段落高度重新测量之后,再把块内比例换算为当前页面里的位置。标题、表格或图片块也应采用独立的稳定标识,不要把高度短暂为零的未加载图片直接当成有效锚点。

锚点写入之前先做基础校验,拒绝未知文章、未知内容块和不合法比例。否则历史数据混入了过期主键,恢复阶段会不断尝试不存在的目标,产生难以追踪的空白滚动。把错误挡在保存入口,后面的读取逻辑就可以保持简单。

const catalog = new Map([
  ['canyon', ['lead', 'route', 'summary']],
  ['metro', ['lead', 'route', 'summary']],
  ['cloud', ['lead', 'route', 'summary']]
]);

function capturePosition(articleId, blockId, progress) {
  const blocks = catalog.get(articleId);
  if (!blocks?.includes(blockId) || !Number.isFinite(progress)) {
    return null;
  }
  if (progress < 0 || progress > 1) return null;
  return { articleId, blockId, progress };
}

function recoverPosition(bookmark, visibleBlocks) {
  if (!bookmark || !catalog.has(bookmark.articleId)) {
    return { status: 'REJECTED', blockId: '' };
  }
  if (visibleBlocks.includes(bookmark.blockId)) {
    return { status: 'EXACT', blockId: bookmark.blockId,
      progress: bookmark.progress };
  }
  if (visibleBlocks.includes('lead')) {
    return { status: 'FALLBACK', blockId: 'lead', progress: 0 };
  }
  return { status: 'REJECTED', blockId: '' };
}

capturePosition() 返回不可变的纯数据对象,方便写入应用自身的数据存储层。recoverPosition() 不读取任何页面实例,因此可以独立检查算法,也便于将来支持多设备继续阅读。块已删除时回退到文章开头的 lead,属于有意选择的保守策略:宁可让读者稍微向下寻找,也不要猜测另一块内容与原位置等价。实际产品可以维护旧块到新块的迁移映射,但映射应随内容版本发布,而不是依靠字符串相似度临时决定。

本文的恢复函数不会创建新的 NavDestination,也不会改变 selectedId。它返回的是内容定位意图,UI 侧拿到这个结果后才按当前布局滚动。列表首次挂载、图片尺寸更新、字体切换都可能再次触发布局测量,应将重复的定位请求合并,避免同一帧里多次强制滚动。

阅读工作台开发环境与恢复状态

四、检查宽窄窗口与内容变更后的结果

验证位置保存策略时,重点不在窗口究竟宽多少像素,而在同一锚点能否跨两种布局命中同一内容块。这里选取宽度为 1180 与 620 的两个输入,用相同的 metro/route/0.42 记录进行恢复。随后构造编辑者删除 route 块的版本,再传入一个不在目录里的文章身份。每一种输入都有明确的期望状态,避免用“看起来差不多”评价定位准确性。

const bookmark = capturePosition('metro', 'route', 0.42);
const cases = [
  { width: 1180, blocks: ['lead', 'route', 'summary'], data: bookmark },
  { width: 620, blocks: ['lead', 'route', 'summary'], data: bookmark },
  { width: 1180, blocks: ['lead', 'summary'], data: bookmark },
  { width: 620, blocks: ['lead', 'summary'],
    data: { articleId: 'foreign', blockId: 'route', progress: 0.42 } }
];
const counts = { EXACT: 0, FALLBACK: 0, REJECTED: 0 };
for (const item of cases) {
  const result = recoverPosition(item.data, item.blocks);
  counts[result.status] += 1;
  console.log(`${item.width} ${result.status} ${result.blockId || '-'}`);
}
console.log(`EXACT ${counts.EXACT} / FALLBACK ${counts.FALLBACK} / REJECTED ${counts.REJECTED}`);

预期四条结果依次为 EXACT route、EXACT route、FALLBACK lead、REJECTED,统计为 EXACT 2 / FALLBACK 1 / REJECTED 1。第一、第二条分别检验宽窄容器下的相同锚点;第三条检验内容删改造成的降级;第四条检验非法文章身份。业务检查可以固定这些输入作为持续回归用例,界面层再补充长图加载、系统字体放大和快速折叠过程中的实际视觉检查。

宽窄窗口下的阅读位置恢复

五、持久化与异常恢复需要保留顺序

把锚点保存在应用数据层时,不要在每一帧滚动都同步落盘。更适合在滚动停止、页面离开或短时间合并更新后保存,避免高频写入和频繁触发观察者。保存时连同内容版本记录摘要;若新版本移除了原内容块,迁移逻辑可以准确知道回退发生的原因。保存失败不应打断当前阅读,最新的内存位置仍可继续使用,但页面应允许下次重试。

内容区恢复也有时序问题。Navigation 切到单栏后,详情页可能经历重新测量;如果在内容块尚未完成布局前调用滚动指令,目标元素还没有有效坐标。工程上可以在数据加载完成并拿到有效区域尺寸之后发出一次恢复任务,并以文章身份、布局代次作为请求键。后续的新布局任务到来时,旧任务应被取消,防止较早计算的偏移在新页面上生效。

还需要处理用户主动滚动与自动恢复的竞争。读者已经在新尺寸下滑动,自动恢复就应让位,不能强行把屏幕拉回保存位置。可在恢复任务提交前再次核对当前文章主键、锚点版本和用户输入代次;任何一项变化都放弃旧请求。这类约束能够让阅读体验在图片异步排版和大屏拖动分栏时保持连贯。

六、把稳定身份留在业务层

经过这一步,阅读工作台的目录身份、路由历史、块级位置各自有了明确职责。布局容器知道窗口有多宽,却不决定读者在看哪篇文章;路由栈知道哪张详情页在前台,却不保存几百像素的滚动值;文章内容层保存段落身份与块内进度,并在布局准备好之后完成恢复。三个状态既可协作,也能单独排查异常。

华为开发者文档中的 Navigation 分栏开发 介绍了 NavigationMode.Auto 与分栏布局相关属性;组件区域变化问题 说明 onAreaChange 返回的尺寸信息;NavDestination 子页面 介绍导航子页面的组织方式。对于长期阅读位置,稳定业务锚点比临时坐标更适合承担跨尺寸恢复,像素偏移则应在最后一步由当前布局计算得到。

Logo

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

更多推荐