适合谁看
-
已经有 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 | search、dish_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 页面支持鸿蒙系统直达,关键不是"能不能打开",而是"能不能自然承接":
-
定义 pageId --- 用业务语义而非路由路径
-
判断参数需求 --- 功能页无参数,详情页需要 dishId
-
区分跳转策略 --- 壳路由 go,详情页 go+push
-
接受无前序上下文 --- 页面用参数独立加载
-
补 pending 机制 --- 冷启动时缓存并消费
页面参数、壳路由关系和冷启动上下文都要一起考虑。这一步做好了,鸿蒙系统入口能力才算真正接进产品里。
