鸿蒙搜索直达到 Flutter 页面的全链路:insight_intent.json 配置到 ArkTS Executor 到 Flutter 路由

适合谁看

  • 正在做鸿蒙 InsightIntent 接入但不清楚全链路的开发者

  • 想让 Flutter 应用支持小艺搜索直达的开发者

  • 遇到"搜索配置了但跳转不生效"问题的人

问题背景

鸿蒙的 InsightIntent(小艺搜索直达)允许用户在系统搜索框中输入关键词,直接跳转到应用内的特定页面。对于 Flutter 应用来说,这条链路比普通 Deep Link 更复杂,因为它涉及:

  1. insight_intent.json 的声明式配置

  2. ArkTS 侧的 InsightIntentExecutor 执行器

  3. Flutter 引擎的 ready 时机判断

  4. Flutter 侧 GoRouter 的路由栈管理

任何一层配置错误,都会导致搜索直达失败。

项目中的真实场景

食界探味支持以下搜索直达场景:

搜索关键词 pageId 目标页面 说明
搜索、找菜 search 搜索页 非 shell 路由,需要 push
AI 助手 ai_assistant AI 助手页 需要检查 enableAi 开关
愿望盒 wish_box 愿望盒页 非 shell 路由
食材、食材列表 ingredients 食材列表页 非 shell 路由
探索、发现 explore 首页 shell 路由,用 go
某道菜名 dish_detail 菜品详情页 需要 dishId 参数

这些场景覆盖了 shell 路由和非 shell 路由两种类型,以及需要额外参数(dishId)的场景。

核心实现

第一层:insight_intent.json 配置

insight_intent.json 声明了应用支持的搜索直达意图:

复制代码
{
  "applicable": true,
  "insightIntents": [
    {
      "intentName": "JumpFunctionPage",
      "intentId": 1001,
      "parameters": [
        {
          "name": "pageId",
          "type": "STRING",
          "description": "目标页面标识"
        },
        {
          "name": "dishId",
          "type": "STRING",
          "description": "菜品ID(仅dish_detail页面需要)",
          "required": false
        }
      ]
    }
  ]
}

关键配置点:

  • intentName 必须和 InsightIntentExecutorImpl 中的 JUMP_FUNCTION_PAGE 常量一致

  • parameters 定义了搜索系统可以传递的参数

  • dishId 标记为 required: false,因为不是所有页面都需要

第二层:InsightIntentExecutorImpl 执行器

ArkTS 侧的执行器负责接收搜索系统传来的参数并转发给 Flutter:

复制代码
// InsightIntentExecutorImpl.ets
export default class InsightIntentExecutorImpl extends InsightIntentExecutor {
  private static readonly JUMP_FUNCTION_PAGE = 'JumpFunctionPage';

  onExecuteInUIAbilityForegroundMode(
    name: string,
    param: Record<string, Object>,
    pageLoader: window.WindowStage
  ): Promise<insightIntent.ExecuteResult> {
    switch (name) {
      case InsightIntentExecutorImpl.JUMP_FUNCTION_PAGE:
        return this.jumpFunctionPage(param);
      default:
        break;
    }
    return Promise.resolve(makeResult(-1, 'unknown intent'));
  }

  private jumpFunctionPage(
    param: Record<string, Object>
  ): Promise<insightIntent.ExecuteResult> {
    return new Promise<insightIntent.ExecuteResult>((resolve) => {
      // 1. 校验 pageId 类型
      if (typeof param?.pageId !== 'string') {
        resolve(makeResult(-1, 'pageId type error'));
        return;
      }

      const pageId: string = param.pageId as string;
      const dishId = typeof param?.dishId === 'string' ? param.dishId as string : undefined;

      // 2. 校验 pageId 合法性
      if (!VALID_PAGE_IDS.includes(pageId)) {
        resolve(makeResult(-1, `unknown pageId: ${pageId}`));
        return;
      }

      // 3. dish_detail 页面必须有 dishId
      if (pageId === 'dish_detail' && (!dishId || dishId.length === 0)) {
        resolve(makeResult(-1, 'dishId type error'));
        return;
      }

      // 4. 尝试通过 Plugin 推送到 Flutter
      const plugin: IntentNavigationPlugin | null = IntentNavigationPlugin.getInstance();
      if (plugin !== null) {
        plugin.navigateToPage(pageId, dishId);
      } else {
        // Flutter 引擎未 ready,走 pending 缓存
        IntentNavigationPlugin.setPendingNavigation(pageId, dishId);
        console.info(TAG, `Flutter not ready, pageId "${pageId}" stored as pending`);
      }

      resolve(makeResult(0, 'success'));
    });
  }
}

执行器的设计要点:

  • VALID_PAGE_IDS 白名单防止非法 pageId 传入 Flutter

  • dish_detail 页面做额外校验,确保 dishId 存在

  • 先尝试 IntentNavigationPlugin.getInstance(),如果为 null(Flutter 未 ready)则走 pending 缓存

  • 返回 ExecuteResult 给搜索系统,告知执行结果

第三层:IntentNavigationPlugin 的两种推送路径

复制代码
// IntentNavigationPlugin.ets
navigateToPage(pageId: string, dishId?: string): void {
  if (this.channel) {
    // Flutter 已 ready,直接推送
    console.info(TAG, `pushing pageId "${pageId}" to Flutter`);
    const args = new Map<string, Object>();
    args.set('pageId', pageId);
    if (dishId) {
      args.set('dishId', dishId);
    }
    this.channel.invokeMethod('onIntentNavigation', args);
  } else {
    // Flutter 未 ready,缓存
    console.warn(TAG, `channel not ready, storing pageId "${pageId}" as pending`);
    IntentNavigationPlugin.pendingNavigation = { pageId, dishId };
  }
}

第四层:Flutter 侧路由执行

Flutter 侧在 IntentNavigationChannel 中处理两种来源的导航:

  1. 实时推送onIntentNavigation 事件(来自 onNewWantInsightIntentExecutor

  2. 延迟消费consumePendingNavigation 请求(来自 onCreate 后的 _consumePending

    // intent_navigation_channel.dart
    static const _pageIdToRoute = <String, String>{
    'search': '/search',
    'ai_assistant': '/ai-assistant',
    'wish_box': '/wish-box',
    'ingredients': '/ingredients',
    'explore': '/explore',
    };

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

    static void _navigate(_NavigationPayload payload) {
    // AI 助手特殊处理:检查开关
    if (payload.pageId == 'ai_assistant' && !AppConfig.enableAi) {
    AppLogger.info('Intent navigation blocked: pageId="${payload.pageId}"');
    _router?.go('/explore');
    return;
    }

    // dish_detail 特殊处理:需要先 go 再 push
    if (payload.pageId == 'dish_detail') {
    final dishId = payload.dishId;
    if (dishId == null || dishId.isEmpty) {
    AppLogger.warning('Missing dishId for dish_detail intent');
    return;
    }
    const homeRoute = '/explore';
    final detailRoute = '/dish/$dishId';
    _router?.go(homeRoute);
    scheduleMicrotask(() {
    _router?.push(detailRoute);
    });
    return;
    }

    // 通用处理
    final route = _pageIdToRoute[payload.pageId];
    if (route == null) {
    AppLogger.warning('Unknown intent pageId: ${payload.pageId}');
    return;
    }

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

路由策略的关键区别:

路由类型 跳转方式 原因
shell 路由(/explore 等) go(route) 底部 Tab 切换,直接替换当前页面
非 shell 路由(/search 等) go('/explore') + push(route) 需要在路由栈中有首页作为根
详情路由(/dish/:id go('/explore') + push(route) push 页面不能直接 go

关键代码位置

  • app/ohos/entry/src/main/resources/rawfile/insight_intent.json --- 搜索直达声明

  • app/ohos/entry/src/main/ets/entryability/InsightIntentExecutorImpl.ets --- 执行器实现

  • app/ohos/entry/src/main/ets/plugins/IntentNavigationPlugin.ets --- 参数缓存与推送

  • app/lib/core/platform/intent_navigation_channel.dart --- Flutter 侧路由执行

鸿蒙侧实现

鸿蒙侧涉及三个文件的协作:

  1. insight_intent.json:声明意图名称和参数,供搜索系统索引

  2. InsightIntentExecutorImpl.ets :实现 InsightIntentExecutor,在 onExecuteInUIAbilityForegroundMode 中处理搜索命中

  3. IntentNavigationPlugin.ets:作为 Executor 和 Flutter 之间的桥梁

Executor 的执行模式有两种:

  • onExecuteInUIAbilityForegroundMode:应用在前台时执行

  • onExecuteInUIAbilityBackgroundMode:应用在后台时执行(本项目未使用)

Flutter 侧实现

Flutter 侧的核心设计是路由映射表和跳转策略:

  • _pageIdToRoute:将 pageId 映射为 GoRouter 路由路径

  • _shellRoutes:标识哪些路由是底部 Tab 路由(用 go

  • _navigate:根据路由类型选择 gogo + push 组合

常见坑

  • 坑 1:insight_intent.json 的 intentName 和 Executor 里的常量不一致 。搜索系统通过 intentName 匹配 Executor,如果拼写不一致,搜索命中后找不到执行器。

  • 坑 2:VALID_PAGE_IDS 白名单遗漏 。新增页面后忘记在 InsightIntentExecutorImplVALID_PAGE_IDS 中添加,导致搜索直达被拒绝。

  • 坑 3:Flutter 侧 _pageIdToRoute 和 Executor 的 VALID_PAGE_IDS 不同步。两边维护各自的 pageId 列表,新增页面时容易遗漏一边。

  • 坑 4:scheduleMicrotask 时序问题gopush 必须分两帧执行,否则 GoRouter 可能合并操作。但如果 scheduleMicrotask 被其他微任务干扰,跳转可能失败。

  • 坑 5:搜索直达在应用冷启动时不生效 。如果 insight_intent.json 配置正确但搜索不生效,检查 InsightIntentExecutorImpl 是否在 module.json5 中正确声明。

可复用模板

复制代码
// Flutter 侧 - 搜索直达路由处理模板
class SearchIntentRouter {
  static const _channel = MethodChannel('com.example.intent_navigation');
  static GoRouter? _router;
  static const _pageIdToRoute = <String, String>{
    'home': '/home',
    'detail': '/detail',
    'settings': '/settings',
  };
  static const _shellRoutes = {'/home', '/profile'};

  static void init(GoRouter router) {
    _router = router;
    _channel.setMethodCallHandler((call) async {
      if (call.method == 'onIntentNavigation') {
        _navigate(call.arguments);
      }
    });
    _consumePending();
  }

  static Future<void> _consumePending() async {
    try {
      final result = await _channel.invokeMethod<Object?>('consumePendingNavigation');
      if (result is Map) _navigate(result);
    } on MissingPluginException {}
  }

  static void _navigate(Object? args) {
    if (args is! Map) return;
    final pageId = args['pageId'] as String?;
    final extra = args['extra'] as String?;
    if (pageId == null) return;

    final route = _pageIdToRoute[pageId];
    if (route == null) return;

    if (_shellRoutes.contains(route)) {
      _router?.go(route);
    } else {
      _router?.go('/home');
      scheduleMicrotask(() => _router?.push(route));
    }
  }
}

// 鸿蒙侧 - InsightIntentExecutor 模板
const VALID_PAGE_IDS: string[] = ['home', 'detail', 'settings'];

export default class MyIntentExecutor extends InsightIntentExecutor {
  onExecuteInUIAbilityForegroundMode(
    name: string,
    param: Record<string, Object>,
    pageLoader: window.WindowStage
  ): Promise<insightIntent.ExecuteResult> {
    if (name !== 'JumpPage') {
      return Promise.resolve({ code: -1, result: { message: 'unknown' } });
    }

    const pageId = param?.pageId as string;
    if (!pageId || !VALID_PAGE_IDS.includes(pageId)) {
      return Promise.resolve({ code: -1, result: { message: 'invalid pageId' } });
    }

    const plugin = NavigationPlugin.getInstance();
    if (plugin) {
      plugin.navigateToPage(pageId);
    } else {
      NavigationPlugin.setPending(pageId);
    }

    return Promise.resolve({ code: 0, result: { message: 'success' } });
  }
}

本篇总结

鸿蒙搜索直达 Flutter 页面的全链路是:insight_intent.json 声明意图 → InsightIntentExecutor 校验参数 → IntentNavigationPlugin 推送或缓存 → IntentNavigationChannel 解析并执行 GoRouter 跳转。每一层都有自己的校验和兜底逻辑,理解这条链路的关键在于搞清楚"参数从哪里来、经过哪些校验、最终如何变成 Flutter 的路由跳转"。