ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【HarmonyOS 7新能力|044】平行视界工程封装:把接入逻辑放进可维护的分层结构

【HarmonyOS 7新能力|044】平行视界工程封装:把接入逻辑放进可维护的分层结构

【HarmonyOS 7新能力|044】平行视界工程封装:把接入逻辑放进可维护的分层结构

列表与详情在手机上适合前后跳转,到了平板或 2in1,如果仍把详情铺满整块大屏,不但浪费空间,也破坏用户边看边选的连续性。平行视界类布局能让同一应用的两个关联页面并排协同,但真正难点是窗口尺寸变化、返回栈、选中状态和编辑草稿如何保持一致。单纯把两个页面放进Row,无法解决导航语义。

说明:本文的ParallelViewPort、PaneCoordinator等为教学抽象,并非 HarmonyOS SDK 的真实接口。平行视界支持范围、窗口能力、配置与 API 签名,请以当前官方文档和目标 SDK 为准。

1. 分屏首先是导航模式变化

单栏模式中,列表进入详情会压入新路由;双栏模式中,列表保留在主区,详情在从区替换。两种模式应表达同一用户任务,而不是维护两套路由。

export interface NavigationIntent { route: string params: Record<string, string> relation?: 'primary' | 'detail' | 'supporting' sourceId?: string }

页面只发出导航意图,布局协调器根据当前模式决定压栈还是更新从区。

2. 四层架构稳定职责

页面与导航层负责内容和用户事件;分屏编排层决定主从配对、比例和切换;窗口能力适配层读取实际尺寸与系统支持;路由与状态仓库保存逻辑路径、选中项和恢复快照。

export interface ParallelViewPort { inspectWindow(): Promise<WindowSnapshot> applyLayout(layout: PaneLayout): Promise<ApplyResult> } export interface RouteStateStore { getSnapshot(): Promise<RouteSnapshot> saveSnapshot(snapshot: RouteSnapshot): Promise<void> }

平台能力变化停在适配层,业务页面无需判断具体设备型号。

3. 用有效宽度判断布局

设备类别不能代表当前窗口。平板处于小窗时也可能只能单栏,2in1 拖动窗口后则会跨越断点。决策应使用内容区有效宽度、方向和安全区域。

export interface WindowSnapshot { widthVp: number heightVp: number orientation: 'portrait' | 'landscape' avoidLeftVp: number avoidRightVp: number epoch: number } function effectiveWidth(window: WindowSnapshot): number { return window.widthVp - window.avoidLeftVp - window.avoidRightVp }

断点来自设计与实机验证,不在多个页面重复硬编码。

4. 页面配对需要显式规则

不是任意两个页面都适合并排。列表与详情、目录与阅读、设置分类与配置页通常具有明确主从关系。配对表定义允许组合、最小宽度和空详情行为。

export interface PanePairRule { primaryRoute: string detailRoutes: readonly string[] minWidthVp: number emptyDetail: 'placeholder' | 'autoSelect' | 'hidden' }

未知页面回到单栏导航,不能把不相关内容强行拼在一起。

5. 平行视界布局决策流程

流程依次采样窗口、判断断点、选择页面配对、同步状态、应用布局,并在窗口或路由变化后恢复。小窗、页面缺失与状态冲突走安全旁路。

export type PaneMode = 'single' | 'dual' export interface PaneDecision { revision: number mode: PaneMode primaryRoute: string detailRoute?: string ratio?: number reason: string }

决策与渲染分离,纯策略函数可以用多组窗口尺寸测试。

6. 路由栈只保留一份逻辑历史

如果单双栏各自维护历史,窗口变化后返回行为会混乱。仓库保存逻辑访问序列,渲染层只选择如何投影到一个或两个容器。

export interface LogicalRouteEntry { id: string route: string params: Record<string, string> parentId?: string visitedAt: number } export interface RouteSnapshot { entries: readonly LogicalRouteEntry[] activeId: string selectedEntityId?: string }

从双栏缩回单栏时,当前详情成为顶层活动路由;返回后回到对应列表状态。

7. 主从状态通过标识同步

列表不要直接引用详情组件实例。主区发布选中实体 ID,详情区通过 ViewModel 或仓库读取对应数据;编辑结果提交后更新同一数据源。

export interface SelectionState { entityId?: string sourceRoute: string revision: number } function selectEntity(current: SelectionState, entityId: string): SelectionState { if (current.entityId === entityId) return current return { ...current, entityId, revision: current.revision + 1 } }

版本号帮助拒绝旧异步请求,避免快速选择时详情回跳。

8. 输入草稿不能随布局切换丢失

窗口跨越断点可能销毁并重建容器。编辑草稿属于业务状态,必须由页面 ViewModel 或专用仓库持有,不应只放在组件局部临时变量中。

export interface EditDraft { entityId: string fields: Record<string, string> dirty: boolean updatedAt: number } export interface DraftStore { save(draft: EditDraft): Promise<void> restore(entityId: string): Promise<EditDraft | undefined> }

布局变化只重建视图,不触发自动提交或清空。退出未保存内容仍按产品规则确认。

9. 比例设计要有最小约束

双栏不是简单五五分。列表需保证标题和操作可读,详情需保留内容最小宽度。比例策略结合有效宽度计算,并在约束无法同时满足时退回单栏。

export function resolveRatio(width: number, primaryMin: number, detailMin: number): number | undefined { if (width < primaryMin + detailMin) return undefined const preferred = 0.38 return Math.max(primaryMin / width, Math.min(preferred, 1 - detailMin / width)) }

分割线、拖动和焦点状态同样需要清晰的视觉与键盘反馈。

10. 窗口变化采用版本化恢复

旋转、折叠或拖动可能连续产生多个窗口事件。协调器对事件去抖,并给决策递增版本;旧布局应用结果不得覆盖新决策。

async function applyLatest(decision: PaneDecision): Promise<void> { const result = await adapter.applyLayout(toLayout(decision)) if (decision.revision !== state.desiredRevision) return if (result.ok) state.confirm(decision) else state.fallbackToSingle(result.code) }

失败时保持单栏可用路径,不让两个半完成页面同时出现。

11. 返回键与焦点必须一致

双栏时返回可先关闭临时详情或回到上一个逻辑实体;单栏时按逻辑路由栈返回。鼠标、键盘和触控需要一致焦点顺序,详情更新后不应无故把焦点抢回主区。

export function resolveBack(snapshot: RouteSnapshot, mode: PaneMode): BackAction { if (mode === 'dual' && hasTransientDetail(snapshot)) return { kind: 'closeDetail' } if (snapshot.entries.length > 1) return { kind: 'popLogicalRoute' } return { kind: 'systemBack' } }

根页面返回遵循系统行为,不制造无法退出的自定义循环。

12. 验收清单与总结

测试覆盖手机竖屏、横屏、小窗、平板单双栏切换、2in1 连续缩放、深浅主题和大字体。验证列表滚动位置、选中项、详情内容、编辑草稿和返回栈在切换后保持一致;页面缺失或布局调用失败时能退回单栏;底部操作避开系统导航区域。

平行视界的工程本质是同一逻辑导航在不同窗口中的两种投影。通过显式页面配对、单一逻辑路由栈、共享状态、版本化布局决策和安全回退,应用才能在大屏充分利用空间,又在窗口变化时保持连续、可理解的用户任务。

十三、接入前检查清单

平行视界工程封装:把接入正式接入前,工程侧要过一遍检查清单:SDK 版本与目标 API 版本匹配、权限在清单中声明且按需申请、能力开关在专用配置层管理、包体积增量符合预算。任何一项不过就先停,带着不匹配的版本往上冲,排错成本会翻倍。

interface PrecheckItem { name: string; pass: boolean; note?: string } const checklist: PrecheckItem[] = [ { name: 'SDK/API版本匹配', pass: false }, { name: '权限声明完整', pass: false }, { name: '开关集中管理', pass: false }, { name: '包体积增量<预算', pass: false }, ] const allPass = checklist.every(x => x.pass)

检查清单跑通的标志是:新建工程按清单接入一次成功,且不依赖任何口头经验。做不到,说明文档还缺关键步骤。

十四、常见坑与版本适配记录

平行视界工程封装:把接入最常踩的坑集中在三处:示例代码用的旧接口在新版本被拆分、默认配置与生产预期不一致、调试环境与真机行为有差异。每踩一个坑就记进版本适配表,注明坑点、影响版本、修复方式与验证方式。

interface Pitfall { issue: string; sinceApi: string; fix: string; verifiedOn: string[] } const pitfalls: Pitfall[] = [] function knownPit(api: string): Pitfall[] { return pitfalls.filter(p => p.sinceApi === api) }

适配表的价值在升级时兑现:每次目标版本变更,先对照表过一遍已知坑,能省下一整轮盲目排查。

十五、回归口径与停止线

平行视界工程封装:把接入的任何改动合入前都要回答同一个问题:怎么证明没有把好的部分改坏。回归口径必须落在纸面:上一版通过的用例清单全量重跑、上一版记录的基线值全量对比、新增能力补一条专属用例。三件事缺一件,回归就是走过场。

interface RegressionGate { lastPassedCases: number; rerun: number; baselineCompare: 'PASS' | 'FAIL'; newCaseAdded: boolean } function gateOk(g: RegressionGate): boolean { return g.rerun === g.lastPassedCases && g.baselineCompare === 'PASS' && g.newCaseAdded }

停止线同样要提前写明:同一问题打回三次、回归连挂两轮、或验证环境与目标环境指纹不一致,满足其一就停下来复盘流程,而不是继续硬修。把停止线当成流程的一部分,才能避免在小问题上耗掉整块的排期。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进