鸿蒙如何把一个已有 Flutter 页面改造成支持系统直达

适合谁看

  • 已经有 Flutter 页面,想加鸿蒙系统直达的人

  • 正在做 Intents Kit 或冷启动导航的人

  • 想避免"能跳到页面但体验很怪"的开发者

问题背景

鸿蒙系统直达和应用内点击进入,最大的不同是:

维度 应用内点击 系统直达
页面上下文 已经准备好 未必准备好
参数来源 前一个页面传值 系统入口传参
壳路由状态 正确 未必正确
页面栈 有前序页面 可能没有

所以"原来能打开的页面",不一定天然适合被系统直接打开。

项目中的真实场景

食界探味当前已经支持系统直达的页面:

页面 pageId 额外参数 跳转方式
搜索页 search 直接 go
AI 助手页 ai_assistant 直接 go
心愿单页 wish_box 直接 go
探索页 explore 直接 go
菜品详情页 dish_detail dishId go + push

核心实现

第一步:给页面定义稳定的"系统语义入口"

不要直接让系统入口认识 Flutter route path。更稳的做法是先定义 pageId

为什么要用 pageId 而不是路由路径:

方式 示例 问题
路由路径 /search/dish/:id 和 Flutter 实现耦合
pageId searchdish_detail 和实现解耦

食界探味的 pageId 定义:

复制代码
{
  "pageId": {
    "type": "string",
    "enum": [
      { "value": "search", "displayName": "搜索美食" },
      { "value": "wish_box", "displayName": "心愿单" },
      { "value": "ingredients", "displayName": "食材探索" },
      { "value": "explore", "displayName": "探索美食" },
      { "value": "dish_detail", "displayName": "查看菜品详情" }
    ]
  }
}

这一步的价值: 把"鸿蒙系统怎么叫这个页面"和"Flutter 内部怎么实现这个页面"拆开。以后改路由命名,系统入口整条链不用跟着改。

第二步:判断页面是否需要额外参数

并不是所有页面都能用同一种直达模型:

页面类型 是否需要参数 示例
功能页直达 不需要 search、wish_box、explore
详情页直达 需要 dishId dish_detail
带上下文的直达 需要 query ai_assistant(可带 initialQuery)

食界探味的参数校验:

复制代码
// InsightIntentExecutorImpl.ets

// 功能页:只需要 pageId
if (!VALID_PAGE_IDS.includes(pageId)) {
  resolve(makeResult(-1, `unknown pageId: ${pageId}`));
  return;
}

// 详情页:还需要 dishId
if (pageId === 'dish_detail' && (!dishId || dishId.length === 0)) {
  resolve(makeResult(-1, 'dishId type error'));
  return;
}

第三步:壳路由页面和独立详情页的承接方式不同

这是"已有页面适配系统直达"时最容易忽略的。

食界探味的两种跳转策略:

复制代码
// intent_navigation_channel.dart

static const _shellRoutes = <String>{
  '/explore', '/inspiration', '/collection', '/profile',
};

static void _navigate(_NavigationPayload payload) {
  // 特殊处理:详情页
  if (payload.pageId == 'dish_detail') {
    final dishId = payload.dishId;
    if (dishId == null || dishId.isEmpty) return;
    _router?.go('/explore');  // 先回到壳路由
    scheduleMicrotask(() {
      _router?.push('/dish/$dishId');  // 再 push 详情页
    });
    return;
  }

  // 通用处理:普通页面
  final route = _pageIdToRoute[payload.pageId];
  if (route == null) return;

  if (_shellRoutes.contains(route)) {
    _router?.go(route);  // 壳路由直接 go
  } else {
    _router?.go('/explore');  // 非壳路由先回主页
    scheduleMicrotask(() {
      _router?.push(route);  // 再 push
    });
  }
}

为什么壳路由和详情页要分开处理:

页面类型 跳转方式 原因
壳路由页(explore) go(route) 直接切换 Tab
非壳路由页(search) go('/explore') + push(route) 先回主页,再 push,返回栈正确
详情页(dish/:id) go('/explore') + push('/dish/$dishId') 先回主页,再 push,带参数

如果用同一种方式跳转:

复制代码
❌ 所有页面都用 go(route):
  详情页直接 go('/dish/xxx')
  → 返回时回到探索页(正确)
  → 但从搜索页进详情页时,返回栈会丢失搜索页

✅ 壳路由 go + 非壳路由 push:
  详情页 go('/explore') + push('/dish/xxx')
  → 返回时先回到探索页
  → 再返回时回到上一个页面
  → 返回栈正确

第四步:页面本身要接受"没有前序页面上下文"

系统直达进来的页面,不能默认认为:

  • 一定是从前一个页面点进来

  • 一定已经有完整内存态

这意味着页面要更多依赖:

  • 路由参数

  • 首次数据加载

菜品详情页的例子:

复制代码
// dish_detail_screen.dart

class DishDetailScreen extends ConsumerStatefulWidget {
  final String dishId;  // 从路由参数获取,不依赖前序页面

  const DishDetailScreen({super.key, required this.dishId});

  @override
  ConsumerState<DishDetailScreen> createState() => _DishDetailScreenState();
}

class _DishDetailScreenState extends ConsumerState<DishDetailScreen> {
  @override
  Widget build(BuildContext context) {
    final dishFuture = ref.watch(_dishProvider(widget.dishId));  // 用 dishId 独立加载
    // ...
  }
}

关键点: 详情页必须把"靠参数独立完成首次加载"当成硬要求。系统直达进来时,没有前序页面帮你准备数据。

第五步:补一层"Flutter 未 ready 时如何补消费"

系统直达不是总发生在 Flutter 完全初始化之后。所以需要 pending 机制:

复制代码
// IntentNavigationPlugin.ets

navigateToPage(pageId: string, dishId?: string): void {
  if (this.channel) {
    // Flutter 已 ready,直接推送
    this.channel.invokeMethod('onIntentNavigation', args);
  } else {
    // Flutter 未 ready,先存到 pendingNavigation
    IntentNavigationPlugin.pendingNavigation = { pageId, dishId };
  }
}

// intent_navigation_channel.dart

static void init(GoRouter router) {
  _router = router;
  _channel.setMethodCallHandler((call) async {
    // 监听实时推送
  });
  _consumePending();  // 主动消费 pending
}

static Future<void> _consumePending() async {
  try {
    final payload = await _channel.invokeMethod<Object?>('consumePendingNavigation');
    final navigation = _parseArguments(payload);
    if (navigation != null) _navigate(navigation);
  } on MissingPluginException {
    // 非鸿蒙平台,忽略
  }
}

这段代码说明:一个已有 Flutter 页面要真正支持系统直达,不只是路由写对,还要把冷启动时机问题也处理掉。

完整的改造流程图

复制代码
已有 Flutter 页面(只支持应用内点击)
  │
  ▼
第 1 步:定义 pageId
  → insight_intent.json 添加 enum
  │
  ▼
第 2 步:判断是否需要额外参数
  → dish_detail 需要 dishId
  │
  ▼
第 3 步:在 Flutter 边界层加路由映射
  → intent_navigation_channel.dart 加 _pageIdToRoute
  → 区分壳路由 go 和详情页 go+push
  │
  ▼
第 4 步:页面接受无前序上下文
  → 详情页用 dishId 独立加载数据
  → 不依赖前序页面传值
  │
  ▼
第 5 步:补 pending 机制
  → IntentNavigationPlugin 缓存 pending
  → Flutter 初始化后 _consumePending()
  │
  ▼
第 6 步:在 EntryAbility 注册
  → configureFlutterEngine 添加插件
  → onCreate/onNewWant 处理参数
  │
  ▼
已有 Flutter 页面支持鸿蒙系统直达

关键代码位置

文件 作用
app/ohos/entry/src/main/resources/base/profile/insight_intent.json pageId 定义
app/ohos/entry/src/main/ets/entryability/InsightIntentExecutorImpl.ets 参数校验
app/ohos/entry/src/main/ets/plugins/IntentNavigationPlugin.ets pending 缓存
app/lib/core/platform/intent_navigation_channel.dart 路由映射
app/lib/app.dart GoRouter 页面定义

常见坑

  • 直接把系统入口绑到 Flutter route path --- 应该用 pageId 解耦

  • 页面需要参数,却没有在入口层校验 --- dish_detail 必须校验 dishId

  • 壳路由页和详情页用同一套跳转方式 --- 详情页需要 go+push

  • 页面默认前序状态一定存在 --- 系统直达进来时可能没有前序页面

  • 只在热启动下测试通过 --- 冷启动时 pending navigation 的消费链也要验证

  • 路由能跳到页面,但页面仍然依赖前序页面传值 --- 系统直达进去后是空态

可复用模板

系统直达改造模板

复制代码
已有 Flutter 页面
  │
  ├─ 1. 定义 pageId(insight_intent.json)
  ├─ 2. 判断是否需要额外参数(dish_detail 需要 dishId)
  ├─ 3. 在 Flutter 边界层加路由映射(_pageIdToRoute)
  ├─ 4. 区分壳路由 go 和详情页 go+push
  ├─ 5. 页面接受无前序上下文(用参数独立加载)
  ├─ 6. 补 pending 机制(冷启动缓存 + 消费)
  └─ 7. 在 EntryAbility 注册插件

路由跳转策略模板

复制代码
static void _navigate(NavigationPayload payload) {
  // 壳路由:直接 go
  if (_shellRoutes.contains(route)) {
    _router?.go(route);
    return;
  }

  // 非壳路由/详情页:先回主页,再 push
  _router?.go('/home');
  scheduleMicrotask(() {
    _router?.push(route);
  });
}

页面独立加载模板

复制代码
class DetailScreen extends ConsumerStatefulWidget {
  final String id;  // 从路由参数获取

  @override
  Widget build(BuildContext context) {
    final data = ref.watch(dataProvider(id));  // 用 id 独立加载
    // 不依赖前序页面传值
  }
}

本篇总结

让一个已有 Flutter 页面支持鸿蒙系统直达,关键不是"能不能打开",而是"能不能自然承接":

  1. 定义 pageId --- 用业务语义而非路由路径

  2. 判断参数需求 --- 功能页无参数,详情页需要 dishId

  3. 区分跳转策略 --- 壳路由 go,详情页 go+push

  4. 接受无前序上下文 --- 页面用参数独立加载

  5. 补 pending 机制 --- 冷启动时缓存并消费

页面参数、壳路由关系和冷启动上下文都要一起考虑。这一步做好了,鸿蒙系统入口能力才算真正接进产品里。