面试备战 Flutter 23:GoRouter、Dio 与混合工程技术选型

面试备战 Flutter 23:GoRouter 与 Dio 工程化

go_router 和 dio 都不是“会用 API”就够。面试更关心的是:

  • 路由是否能承接深链、登录态、底部 Tab、多页面栈?
  • 网络层是否有统一鉴权、错误映射、取消请求、重试和可测试边界?

一句话结论:

go_router 解决的是 URL 驱动的声明式路由和嵌套路由治理;Dio 解决的是请求生命周期、拦截器、取消、错误映射和网络层工程化。

1. go_router 是什么?

go_router 是 Flutter 官方 packages 体系下的声明式路由包,基于 Router API,强调 location 和页面状态的映射。

传统 Navigator 更像命令式:

push A
push B
pop

go_router 更强调:

当前 location = /users/42
-> 应用应该展示 UserDetailPage(id: 42)

这让它天然适合:

  • Web URL。
  • 深链。
  • 登录重定向。
  • 嵌套路由。
  • 底部 Tab 独立导航栈。
  • 页面参数标准化。

2. go 和 push 的区别

go

go 表示“把应用切换到某个 location”。

context.go('/home');

它更像声明当前目标位置,常用于:

  • 登录成功进入首页。
  • 退出登录回登录页。
  • 底部 Tab 切换。
  • 深链落点。
  • 根级页面切换。

push

push 表示“在当前导航栈上再压一个页面”。

context.push('/users/42');

常用于:

  • 详情页。
  • 编辑页。
  • 临时流程页。
  • 从列表进入二级页面。

面试话术

go 更偏 URL 状态切换,会让路由栈匹配当前 location;push 更像传统 Navigator push,在当前栈上叠加页面。Tab、登录态切换、深链通常用 go;详情、编辑、临时流程通常用 push。

3. redirect 怎么设计?

登录拦截不应该散落在页面 build 里,而应该在路由层统一处理。

final router = GoRouter(
  refreshListenable: authState,
  redirect: (context, state) {
    final loggedIn = authState.loggedIn;
    final loggingIn = state.matchedLocation == '/login';

    if (!loggedIn && !loggingIn) {
      return '/login';
    }

    if (loggedIn && loggingIn) {
      return '/home';
    }

    return null;
  },
  routes: routes,
);

注意点:

  • redirect 应尽量是纯判断,不要在里面发请求。
  • 登录态变化后 router 要能 refresh。
  • 防止循环重定向。
  • 保存原始目标页,登录成功后跳回。

例如:

if (!loggedIn && !loggingIn) {
  final from = Uri.encodeComponent(state.uri.toString());
  return '/login?from=$from';
}

4. path 参数和 query 参数

路径参数适合资源身份:

/users/:id

query 参数适合筛选条件:

/search?q=flutter&page=2

示例:

GoRoute(
  path: '/users/:id',
  builder: (context, state) {
    final id = state.pathParameters['id']!;
    return UserPage(id: id);
  },
);

参数应该尽早转换成强类型对象,不要让字符串散落到业务层。

5. ShellRoute 和 StatefulShellRoute

ShellRoute

适合给一组子路由套公共壳:

  • 底部导航。
  • 顶部 AppBar。
  • 侧边栏。
  • 登录后的主框架。

StatefulShellRoute

适合底部 Tab 独立页面栈。

例如:

Tab A: /home -> /home/detail
Tab B: /message -> /message/chat
Tab C: /profile

用户切换 Tab 后,每个 Tab 的历史栈应该保留。StatefulShellRoute 比普通 ShellRoute 更适合这种场景。

6. go_router 常见坑

  • redirect 里做异步副作用。
  • 登录状态变化不触发 router refresh。
  • gopush 混用导致返回栈不符合预期。
  • 多层 Navigator 下 dialog/bottomSheet context 用错。
  • path 字符串散落,缺少统一 RouteName。
  • extra 传复杂对象,刷新或深链后丢失。
  • 页面参数没有强类型转换。

建议:

  • 路由集中定义。
  • name/location 封装。
  • 参数对象化。
  • 鉴权统一 redirect。
  • Tab 使用 StatefulShellRoute。

7. 项目实战:为什么我们没有用 go_router?

我们自己的混合工程没有使用 go_router,而是使用了一套 自研 URL Scheme 路由表 + Navigator 1.0 + Native 容器路由插件

核心链路是:

RouterTools.openFlutterPage
-> Navigator.of(context).push(MaterialPageRoute)
-> RouteSettings(name: url)
-> urlPageMap[url](url, params, uniqueId)

核心文件:

flutter_smartstorehome_package/lib/common/router_tools.dart
flutter_smartstorehome_package/lib/common/router_uri.dart
flutter_smartstorehome_package/lib/page_url_map.dart
flutter_smartstorephone_portal/lib/main.dart
flutter_smartstorephone_portal/lib/routers/url_page_map.dart

路由地址统一定义在 RouterUri

class RouterUri {
  static const String scheme = "beikesteward";
  static const String pageSearchTaskPage = "$scheme://page/search/task";
}

页面注册集中在 urlPageMap

var urlPageMap = <String, PageBuilder>{
  RouterUri.pageSearchTaskPage: (pageName, params, _) {
    return SearchTaskPage(params: params);
  },
};

Flutter 内部跳转使用:

RouterTools.openFlutterPage(
  context,
  url: RouterUri.pageSearchTaskPage,
  params: {'decorateSearchType': type},
);

跨 Native 容器打开 Flutter 页面时,会包装成容器协议:

bhsmartcontent://flutter/beike/container?flutter_url=...

Flutter 壳工程 flutter_smartstorephone_portal 负责把多个业务包的页面表注册给 FlutterRunnerHelper

FlutterRunnerHelper.singleton.registerPageBuilders(homeUrlPage);
FlutterRunnerHelper.singleton.registerPageBuilders(storeUrlPage);
FlutterRunnerHelper.singleton.registerPageBuilders(contentUrlPage);

这说明我们项目的路由不是纯 Flutter App 内导航,而是服务于 Native 主导的混合容器。

为什么这个方案合理?

go_router 的强项是声明式 URL、deep link、redirect、ShellRoute 和 Web 友好。但我们项目的主战场不是纯 Flutter App,而是:

Native App
-> Flutter 容器
-> 多 Flutter 业务包
-> H5
-> Native 页面
-> 后端下发跳转 URL

这种场景更需要一套跨端统一的 URL Scheme,而不是只服务 Flutter 内部的声明式路由。

当前方案的优势:

  • 适合混合工程:Native、Flutter、H5 都可以用同一套 scheme 描述页面。
  • 兼容后端下发跳转:按钮、运营位、任务卡可以下发 btnUrl,Flutter 根据 http/scheme 分发。
  • 和 Native Router 打通:Native 页面、Flutter 页面、H5 页面可以互跳。
  • 保留 Navigator Future 回传:搜索页、选择页可以直接 await RouterTools.openFlutterPage(...) 获取返回值。
  • 壳工程统一承载:Flutter 业务包不需要各自维护 MaterialApp、RouterDelegate 和 engine 容器。
  • 业务接入成本低:新增页面只要补 RouterUriurlPageMap

当前方案的代价

这套方案也有明显短板:

  • 字符串路由不是类型安全的:url 写错、参数 key 写错,编译期发现不了。
  • 参数是 Map:页面自己解析,类型错误运行时才暴露。
  • 未注册路由缺少兜底:如果 urlPageMap[url] 为空,容易空调用崩溃。
  • 手写 query 解析不够稳:应该优先使用 Uri.parse,避免中文、转义、&= 解析问题。
  • 直接 Navigator 调用仍然存在:部分页面绕过 RouterTools,导致埋点、返回、栈治理不统一。
  • 声明式能力弱:登录 redirect、Tab 多栈、URL 状态恢复不如 go_router 系统。

所以结论不是“go_router 更高级,我们落后”,而是:

我们项目选择的是面向混合容器的 URL Router,而不是纯 Flutter 声明式 Router。它牺牲了部分类型安全和声明式能力,换来了 Native/Flutter/H5/后端下发 URL 的统一调度能力。

更务实的优化方向不是直接替换 go_router,而是在现有路由体系上补治理:

RouterUri 常量
-> Typed RouteArgs
-> Uri.parse 参数解析
-> UnknownRoutePage 兜底
-> RouteInterceptor / RouteGuard
-> 路由埋点和日志
-> Native/Flutter 协议文档

8. Dio 应该放在哪一层?

不要让 UI 直接调用 Dio。

推荐分层:

Widget
-> Notifier / Controller
-> Repository
-> ApiClient
-> Dio
-> Interceptor / HttpClientAdapter

UI 关心状态,不关心 HTTP 细节。

Repository 关心业务语义:

abstract interface class UserRepository {
  Future<User> fetchUser(String id);
}

ApiClient 关心接口协议:

class UserApi {
  UserApi(this._dio);

  final Dio _dio;

  Future<User> fetchUser(String id) async {
    final response = await _dio.get('/users/$id');
    return User.fromJson(response.data as Map<String, dynamic>);
  }
}

9. Dio Interceptor 做什么?

Interceptor 适合统一横切逻辑:

  • base header。
  • token 注入。
  • traceId。
  • 日志。
  • 错误映射。
  • refresh token。
  • mock。
  • 重试。

示例:

dio.interceptors.add(
  InterceptorsWrapper(
    onRequest: (options, handler) {
      final token = tokenStore.accessToken;
      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }
      handler.next(options);
    },
    onError: (error, handler) async {
      handler.next(error);
    },
  ),
);

10. refresh token 怎么避免死循环?

刷新 token 最容易踩坑。

错误设计:

请求 A 401
-> 拦截器调用 refresh
-> refresh 也走同一个 Dio
-> refresh 也被拦截
-> 递归/死循环

更稳的方案:

  • refresh 使用单独 Dio 实例。
  • refresh 接口标记 skipAuth。
  • 同一时间只允许一个 refresh。
  • 其他 401 请求等待 refresh 结果。
  • refresh 失败统一退出登录。

伪流程:

request -> 401
if refreshing:
  wait refresh result
else:
  start refresh
retry original request with new token

11. CancelToken 为什么重要?

取消请求不是锦上添花,而是页面生命周期治理。

场景:

  • 页面退出。
  • 搜索关键字变化。
  • Tab 切换。
  • 下拉刷新覆盖旧请求。
  • 重复点击。

示例:

final cancelToken = CancelToken();

final response = await dio.get(
  '/search',
  queryParameters: {'q': keyword},
  cancelToken: cancelToken,
);

cancelToken.cancel('keyword changed');

和 Riverpod 结合:

final searchProvider = FutureProvider.autoDispose.family<SearchResult, String>((ref, keyword) async {
  final cancelToken = CancelToken();
  ref.onDispose(() => cancelToken.cancel('disposed'));

  final response = await dio.get(
    '/search',
    queryParameters: {'q': keyword},
    cancelToken: cancelToken,
  );
  return SearchResult.fromJson(response.data as Map<String, dynamic>);
});

12. 错误建模

不要把 DioException 直接扔给 UI。

应该统一映射成业务错误:

sealed class NetworkError {
  const NetworkError();
}

class TimeoutError extends NetworkError {
  const TimeoutError();
}

class UnauthorizedError extends NetworkError {
  const UnauthorizedError();
}

class ServerError extends NetworkError {
  const ServerError(this.statusCode);

  final int? statusCode;
}

好处:

  • UI 不依赖 dio。
  • 错误文案统一。
  • 重试策略统一。
  • 埋点统一。

13. Dio 工程化检查清单

  • baseUrl 是否按环境区分。
  • token 是否统一注入。
  • refresh token 是否防并发。
  • CancelToken 是否接入页面生命周期。
  • 错误是否统一映射。
  • 日志是否脱敏。
  • 超时是否配置。
  • 是否有 requestId/traceId。
  • Repository 是否屏蔽 HTTP 细节。
  • 测试是否可以替换 ApiClient。

14. 项目实战:为什么我们没有用 Dio?

我们项目的 Flutter 业务包没有使用 Dio。Flutter 侧网络请求走的是:

RequestTools.get/post
-> HomeFlutterPluginAdapter.netGet/netPost
-> MethodChannel(home_flutter_plugin / beikesmartstorepad_flutter_plugin)
-> DecorationSteward 原生壳插件
-> DWHomeNetworkService / BKBPNetworkAPI / DWCommonApiService
-> 公司网关 / 后端

Flutter 侧只描述业务请求:

class RequestTools {
  static Future<BKBPResult> get(String url, {Map<String, dynamic> params, String bizId}) async {
    final map = await HomeFlutterPluginAdapter.netGet(
      url: url,
      params: params,
      bizId: bizId,
    );
    return BKBPResult.fromJson(map);
  }
}

真正发 HTTP 的不是 Flutter,而是 Native 壳工程 DecorationSteward

为什么这么设计?

核心原因是:我们是 Native 主导的混合工程,不是纯 Flutter App。

原生壳已经有成熟网络基础设施,包括:

  • 登录态和 token。
  • 角色和城市信息。
  • 环境网关。
  • Authorization 签名。
  • Cookie 注入。
  • Device 信息。
  • 统一错误码处理。
  • 登录过期处理。
  • 安全策略。
  • 埋点和日志。

如果 Flutter 再用 Dio,就会出现第二套网络体系:

Native 网络体系
Flutter Dio 网络体系

这会带来一批重复建设:

  • token 如何同步?
  • refresh token 谁负责?
  • 登录过期谁弹登录页?
  • 角色切换后 Flutter header 如何刷新?
  • 环境切换如何保持一致?
  • Authorization 签名规则是否要在 Dart 再实现一次?
  • Cookie / WebView / Native 请求如何保持一致?
  • 网络埋点和 requestId 如何统一?

这些问题本质上都属于壳工程能力。让 Flutter 复用 Native 网络层,是更符合当前架构的选择。

当前方案的优势

  • 登录态只维护一套:Flutter 不需要单独管理 token。
  • 安全逻辑不散落到 Dart:签名、设备信息、防抓包等留在原生层。
  • 环境切换统一:测试、预发、生产由原生壳控制。
  • Native/H5/Flutter 请求口径一致:Cookie、User-Agent、角色、城市等统一。
  • 业务包更轻:Flutter 只关心接口 path、参数和模型解析。
  • 多端适配更自然:iOS/Android/Harmony 可以在各自插件里适配平台差异。
  • 复用公司已有网络 SDK:不需要在 Flutter 重造网关、签名、错误治理。

当前方案的代价

  • 每次请求都经过 MethodChannel,有编解码和跨端调用成本。
  • Flutter 侧缺少 Dio 生态能力,例如 Interceptor、CancelToken、下载进度、请求队列。
  • 请求取消能力弱,页面销毁后 Native 请求未必能取消。
  • 返回值类型弱,Native 返回 JSON string,Flutter 再 jsonDecode
  • 调试链路更长,需要同时看 Flutter、Plugin、Native 网络层。
  • 单测不够直接,业务请求依赖 MethodChannel,需要额外 mock。

所以我们的选择可以这样总结:

不用 Dio 不是因为 Dio 不好,而是因为网络治理中心在 Native 壳。Flutter 通过 MethodChannel 委托 Native 发请求,复用登录态、签名、网关、Cookie、环境、安全和埋点能力。这对混合工程是合理取舍。

后续更适合的优化不是“全量迁移 Dio”,而是把现有协议做强:

netGet/netPost
-> netRequest
-> RequestOptions(method/url/params/body/bizId/requestId/timeout)
-> Native 标准响应(code/message/data/requestId/traceId/httpStatus)
-> 支持 cancelRequest(requestId)

15. 面试怎么回答?

可以这样答:

go_router 我会把它当成 URL 驱动的声明式路由系统,而不是 Navigator 替代品。go 用于切换当前 location,适合登录态、Tab、深链;push 用于在当前栈上叠加页面,适合详情和编辑。登录鉴权放 redirect,底部 Tab 独立栈用 StatefulShellRoute,参数尽早转成强类型对象。

Dio 我不会让 UI 直接用,而是放在 ApiClient/Repository 下面。Interceptor 处理 token、日志、错误映射和 refresh token;CancelToken 接页面生命周期,尤其是搜索和 autoDispose provider;DioException 不直接暴露给 UI,而是映射成业务错误。这样网络层可测、可替换、可治理。

但在我们的混合工程里,路由和网络的技术决策不完全按纯 Flutter App 来选。路由上,我们使用 URL Scheme + urlPageMap + FlutterRunnerHelper,是为了打通 Native、Flutter、H5 和后端下发 URL;网络上,我们没有使用 Dio,而是通过 MethodChannel 委托 Native 壳发请求,是为了复用原生已有的 token、签名、网关、Cookie、角色、环境和安全能力。它牺牲了一些 Flutter 侧的类型安全和取消能力,但换来了混合工程的一致性和治理成本优势。

参考资料