HarmonyOS 页面转场分工图

页面转场写到后面,最容易出问题的地方不是“动画好不好看”,而是谁来负责这一层界面。

我以前排查这类问题时,经常看到三种写法混在一起:页面跳转用一个布尔值控制,筛选面板也用同一个布尔值控制,提交中的全屏遮罩还是用它控制。刚开始页面少,看起来没什么;等页面栈深一点,或者用户连续点了几次,就会出现很难解释的现象:返回上一页后面板还在、旧请求回来把新遮罩关掉、详情页退出了但弹层还挂在屏幕上。

这类问题不能只改一个动画参数。更稳的做法是先把三种能力的职责分开:

  • Navigation 负责页面栈,适合列表进详情、详情进编辑这种“页面级变化”。
  • bindSheet 负责当前页面上的半模态面板,适合筛选、排序、选择器这种“当前页面附属操作”。
  • bindContentCover 负责全屏覆盖流程,适合提交确认、预览、支付前确认这种“临时盖住页面但不进入路由栈”的动作。

官方最佳实践里把页面间转场、半模态转场、全模态转场分开讲,就是因为它们不是同一类东西。真正写项目时,先问一句“这一层界面离开页面后还应不应该存在”,答案基本就清楚了。

先定一条规则:不要把所有浮层都塞进页面栈

我会先按下面这张表做判断:

场景 更适合的能力 判断依据
列表进入详情、详情进入编辑 Navigation 用户按返回键时,应该回到上一个页面
当前页筛选、排序、时间选择 bindSheet 它依附当前页面,页面离开时就该一起消失
提交确认、全屏预览、等待结果 bindContentCover 它临时盖住当前页面,但不应该污染页面栈
跨页面公共登录态、全局错误页 单独的全局状态或路由兜底 它不是某一个页面的附属物

这里最容易犯的错,是把“看起来像一个新界面”的东西都当成页面。比如筛选面板,如果塞进 Navigation,返回栈会变复杂;如果把详情页当成一个 bindSheet,后面又要处理分享、编辑、返回恢复状态,迟早会绕回来补路由。

第一个案例:离开页面后,旧面板还留在屏幕上

先看一个很常见的错误写法。页面 A 打开了一个筛选面板,用户还没关面板就进了页面 B,结果旧面板跟到了新页面。

问题不是 bindSheet 不能用,而是“面板属于谁”没有被记录清楚。

@Entry
@Component
struct DemoPage {
  @State showFilterSheet: boolean = false;
  private navStack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.navStack) {
      Column() {
        Button('打开筛选')
          .onClick(() => {
            this.showFilterSheet = true;
          })

        Button('进入详情')
          .onClick(() => {
            this.navStack.pushPathByName('RecipeDetail', { id: 42 });
          })
      }
      .bindSheet($$this.showFilterSheet, this.filterBuilder())
    }
  }

  @Builder
  filterBuilder() {
    Column() {
      Text('筛选条件')
    }
  }
}

这段代码的问题在于:showFilterSheet 只是一个全局布尔值,它没说明这个面板属于哪个页面。用户一旦在面板打开时进入详情页,后续再刷新或回退,当前页面和面板归属就可能对不上。

我更愿意把面板归属绑定到当前页面 owner 上。页面离开前,先清理自己名下的半模态层。

type SurfaceKind = 'sheet' | 'cover';

interface SurfaceRecord {
  kind: SurfaceKind;
  owner: string;
  name: string;
}

class SurfaceStore {
  private records: SurfaceRecord[] = [];

  open(record: SurfaceRecord) {
    this.records = this.records.filter((item) =>
      !(item.owner === record.owner && item.name === record.name)
    );
    this.records.push(record);
  }

  closeByOwner(owner: string) {
    this.records = this.records.filter((item) => item.owner !== owner);
  }

  has(owner: string, name: string): boolean {
    return this.records.some((item) => item.owner === owner && item.name === name);
  }
}

页面里就不用靠一个裸布尔值撑到底了:

@Component
struct RecipeListPage {
  private owner: string = 'RecipeListPage';
  private surfaceStore: SurfaceStore = new SurfaceStore();

  aboutToDisappear() {
    this.surfaceStore.closeByOwner(this.owner);
  }

  openFilterSheet() {
    this.surfaceStore.open({
      kind: 'sheet',
      owner: this.owner,
      name: 'filter-panel'
    });
  }

  isFilterVisible(): boolean {
    return this.surfaceStore.has(this.owner, 'filter-panel');
  }
}

这个改法的重点不是多写一个类,而是把“页面栈”和“当前页面附属层”分开。Navigation 只管页面进出,bindSheet 只管当前页面上挂着的面板。页面离开时,当前页面自己的面板必须自己收掉。

我用一个独立 demo 跑了一下这个模型,验证的是同一件事:从 Home 进入 RecipeDetail 后,属于旧页面的 filter-panel 会被清掉。

{
  "caseOne": {
    "stack": "Home > RecipeDetail",
    "sheet": [
      {
        "type": "sheet",
        "owner": "RecipeDetail",
        "name": "filter-panel"
      }
    ],
    "afterLeave": []
  }
}

这里的 afterLeave 是空数组,说明旧页面离开后没有遗留面板。这个验证点很关键,因为肉眼看页面不一定能立刻发现残留,状态表一打印就很清楚。

第二个案例:旧异步请求回来,把新遮罩关掉了

第二类问题更隐蔽:用户连续点了两次提交。第一次请求比较慢,第二次请求比较快。第二次打开了新的全屏遮罩,但第一次请求结束时直接把遮罩关掉,页面就会出现“明明还在处理,遮罩突然没了”的情况。

错误写法通常是这样:

@State isCoverVisible: boolean = false;

async submit() {
  this.isCoverVisible = true;
  await this.requestSave();
  this.isCoverVisible = false;
}

这段代码看着很顺,但它默认只有一个请求。只要用户重复点击、网络抖动、页面切到后台再回来,旧请求就可能覆盖新请求的状态。

我会给每次全屏流程分配一个 token,只允许最新 token 关闭最新遮罩。

class CoverFlow {
  private activeToken: number = 0;
  private visible: boolean = false;

  start(): number {
    this.activeToken += 1;
    this.visible = true;
    return this.activeToken;
  }

  finish(token: number) {
    if (token !== this.activeToken) {
      return;
    }
    this.visible = false;
  }

  isVisible(): boolean {
    return this.visible;
  }
}

页面调用时不要让异步回调直接改 UI,而是把 token 带回来:

@State submitting: boolean = false;
private coverFlow: CoverFlow = new CoverFlow();

async submitRecipeDraft() {
  const token = this.coverFlow.start();
  this.submitting = true;

  try {
    await this.requestSaveDraft();
  } finally {
    this.coverFlow.finish(token);
    this.submitting = this.coverFlow.isVisible();
  }
}

这时 bindContentCover 只负责显示全屏流程,是否关闭由 CoverFlow 判定。旧请求回来时 token 对不上,就不能关闭新的遮罩。

我同样用 demo 验证了这个顺序。第一次请求 save-draft 回来时已经不是最新 token,所以被忽略;第二次请求 publish-check 才有资格关闭当前遮罩。

{
  "caseTwo": [
    {
      "requestName": "save-draft",
      "ignored": true,
      "token": 1,
      "activeToken": 2,
      "overlays": []
    },
    {
      "requestName": "publish-check",
      "ignored": false,
      "token": 2,
      "activeToken": 2,
      "overlays": []
    }
  ]
}

这类问题如果只看 UI,很容易误判成“遮罩动画有问题”。实际排查时,我会先看三件事:

  • 当前页面栈是不是已经切到新页面。
  • 半模态面板的 owner 是不是还是旧页面。
  • 全屏遮罩关闭时,回调 token 是不是当前最新 token。

三种方案放在一起比较

方案 优点 容易出问题的地方 适合场景
全部用 Navigation 返回逻辑统一 面板、确认框会污染页面栈 真正的页面级跳转
全部用布尔值控制 写起来快 页面离开、异步回调、重复点击时容易串状态 很简单的临时页面
页面栈、半模态、全屏覆盖分层 职责清楚,排查路径稳定 前期要多做一层状态归属 页面复杂、状态多、需要长期维护

我更推荐第三种。它不是为了写得复杂,而是为了让问题有稳定的排查入口。

页面栈异常,就看 NavigationNavPathStack;面板残留,就看 bindSheet 的 owner;全屏遮罩提前关闭,就看 bindContentCover 对应的 token。拆开以后,日志也能拆开,后续维护成本会低很多。

可以封装成一个转场面板管理器

项目里如果页面多,不建议每个页面都自己写一套布尔值。可以抽一个小的 surface manager:

type SurfaceType = 'sheet' | 'cover';

interface OpenSurfaceOptions {
  type: SurfaceType;
  owner: string;
  name: string;
  token?: number;
}

class PageSurfaceManager {
  private surfaces: OpenSurfaceOptions[] = [];
  private latestToken: number = 0;

  nextToken(): number {
    this.latestToken += 1;
    return this.latestToken;
  }

  open(options: OpenSurfaceOptions) {
    this.surfaces = this.surfaces.filter((item) =>
      !(item.owner === options.owner && item.name === options.name)
    );
    this.surfaces.push(options);
  }

  close(owner: string, name: string, token?: number) {
    if (token !== undefined && token !== this.latestToken) {
      return;
    }
    this.surfaces = this.surfaces.filter((item) =>
      !(item.owner === owner && item.name === name)
    );
  }

  closeOwner(owner: string) {
    this.surfaces = this.surfaces.filter((item) => item.owner !== owner);
  }
}

这个封装可以解决两件事:

  • 页面离开时按 owner 清理,避免旧页面的面板跟到新页面。
  • 异步流程按 token 关闭,避免旧请求误关新的遮罩。

如果只是一个简单按钮弹一次面板,用不用这个封装都行;但只要出现列表、详情、编辑、提交、预览这些组合,我会尽早把这层抽出来。越晚抽,后面越容易在每个页面补一堆“临时兜底”。

排查时日志不要只打 visible

这类问题还有一个坑:日志打得太粗。很多页面只打了一句 sheet visible = truecover visible = false,等问题出现时,根本看不出是谁把它打开的,也看不出是谁把它关掉的。

我会把日志拆成四个字段:

interface SurfaceLog {
  routeName: string;
  owner: string;
  surfaceName: string;
  token?: number;
  action: 'open' | 'close' | 'ignore';
}

打开半模态时记录:

this.surfaceManager.open({
  type: 'sheet',
  owner: 'RecipeListPage',
  name: 'filter-panel'
});

logger.info({
  routeName: 'RecipeListPage',
  owner: 'RecipeListPage',
  surfaceName: 'filter-panel',
  action: 'open'
});

关闭全屏覆盖时记录:

this.surfaceManager.close('RecipeEditPage', 'submit-cover', token);

logger.info({
  routeName: 'RecipeEditPage',
  owner: 'RecipeEditPage',
  surfaceName: 'submit-cover',
  token,
  action: token === this.latestToken ? 'close' : 'ignore'
});

这样一来,问题出现时不需要反复猜。看到 ignore 就知道旧异步回调已经回来过,但因为 token 不是最新,所以没有资格动当前 UI。看到 owner 还是旧页面,就知道是页面离开时没有清理自己的半模态层。

哪些情况不适合这么封装

这个方案也不是所有页面都要套。下面几类情况我会直接保持简单写法:

场景 原因 建议
只有一个确认框,没有异步流程 状态范围很窄 一个 @State 就够
页面没有路由切换 不存在旧页面残留 不需要 owner 管理
全屏页本身就是核心页面 应该进入页面栈 Navigation,不要用 cover 假装页面
需要跨页面长期存在 已经不是当前页面附属层 放到全局状态或独立页面

我不会把所有东西都抽成框架。这里之所以抽,是因为页面栈、半模态和全屏覆盖确实有不同生命周期。只要生命周期不同,就值得分开管理;如果生命周期一样,就没必要加复杂度。

最后沉淀成几条检查规则

我现在排查 HarmonyOS 页面转场问题,会按这个顺序看:

  • 先判断它是不是页面级变化。是页面,就进 Navigation;不是页面,就别往页面栈里塞。
  • 再判断它是不是当前页面附属操作。是筛选、排序、选择器,就优先用 bindSheet,并且记录 owner。
  • 如果它是全屏流程,就用 bindContentCover,并给异步操作分配 token。
  • 页面离开前,清掉自己 owner 下的半模态层。
  • 异步回调回来时,先确认 token 是否还是最新,再决定能不能改 UI。
  • 日志不要只打“显示/隐藏”,要把 route、owner、surface name、token 一起打出来。

这套规则的好处是,后面再遇到“返回后还有弹层”“遮罩突然消失”“页面栈里多了一层异常页面”这种问题,不用靠猜。先看它属于页面、半模态还是全屏覆盖,再顺着 owner 和 token 查,基本就能把问题定位到具体一层。

Logo

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

更多推荐