Flutter 项目经验面试笔记:GetX 与 Riverpod 双项目
写给自己看的项目介绍笔记,不是通用 Flutter 知识点(那部分见《11-Flutter面试知识整理》)。素材来自三个真实仓库:
flutter_getx(GetX + Get 命名路由)、flutter_riverpod(Riverpod + GoRouter),以及两者共用的 H5 前端micro-loans-new(用来核对桥接协议 H5 那一侧的真实实现,不是只看 Dart 代码脑补)。目标是整理出一套能在面试里站得住脚的项目介绍话术,重点放在两块:一是"两个项目核心功能差不多、技术栈不同"该怎么讲清楚;二是flutter_riverpod这个更新的项目里,一批真正有工程深度的定制点。
一、两个项目是什么,为什么要维护两套技术栈
项目背景
两个项目都是消费信贷类 App,核心业务流程是"手机号登录 → 身份认证(OCR 识别身份证 + 活体检测)→ 授信 → 后续账单/额度管理",中间穿插大量 H5 混合页面承载具体的业务展示和交互。从代码里能确认的信息:
| 维度 | flutter_getx | flutter_riverpod |
|---|---|---|
| 状态管理 | GetX | Riverpod |
| 路由 | GetX 命名路由(GetPage) | GoRouter(声明式) |
| 依赖里的品牌线索 | credit_kit、flutter_jailbreak_detection | huayajq(包名)、aliyun_idaas_auth、rangers_applog_flutter_plugin |
两个 applicationId 完全不同,说明这不是同一个 App 的两个版本,而是两个独立品牌、独立发布的信贷类产品——这也是为什么同一个人要维护两套技术栈:不是技术选型上的纠结,而是两个不同的产品线,各自有自己的迭代节奏和历史包袱。
面试如果被问"为什么同一个人做两个项目、技术栈还不一样",可以直接按这个逻辑回答:这是公司旗下两个独立的信贷产品,不是同一个 App 换皮。两个产品的核心业务流程高度相似(登录、实名认证、授信、H5 承载业务页),所以技术架构上有大量可以复用的经验和基建(比如 Hybrid 桥接协议,见第六章),但状态管理和路由方案是各自项目独立选型、独立演进的,不是统一强制的。
核心功能对照
两个项目页面结构不同(flutter_getx 是 lib/pages/*,flutter_riverpod 是 lib/src/pages/*),但从目录能看出核心模块高度一致:登录(手机号+验证码)、身份认证(OCR/活体检测,flutter_riverpod 里单独归在 credit 模块下)、WebView 承载业务页、设置/账户/关于/帮助中心/安全中心、注销流程。这一点是面试介绍项目时的一个好切入点:核心功能的相似度,恰好让"两套技术栈实现同一类功能"这件事变成了一个天然的技术对比案例,不需要额外找理由。
二、技术栈全景对比
| 能力 | flutter_getx(GetX) | flutter_riverpod(Riverpod) |
|---|---|---|
| 状态管理 | GetX(GetxController + .obs) | Riverpod(StateNotifierProvider + ConsumerWidget) |
| 路由 | Get 命名路由(GetPage 数组 + Get.toNamed) | GoRouter(声明式 GoRoute 数组) |
| 网络请求 | dio + pretty_dio_logger | dio + pretty_dio_logger(相同) |
| Hybrid WebView | flutter_inappwebview(本地 vendor 插件) | flutter_inappwebview(同源 vendor 插件,且做了额外定制,见第六章) |
| 原生桥接 | native_bridge_common | native_bridge_common(共用) |
| 屏幕适配 | flutter_screenutil | flutter_screenutil(相同技巧,见第三章) |
| 风控/安全类插件 | credit_kit、flutter_jailbreak_detection(越狱检测)、pointycastle(加密) | flutter_face(人脸识别)、aliyun_idaas_auth(阿里云身份认证/一键登录)、pointycastle |
| 埋点/统计 | 手动埋点(Mars.trackXxx,代码里可见调用点) | rangers_applog_flutter_plugin(字节火山引擎),并通过 NavigatorObserver 接入 GoRouter,路由跳转自动上报 |
| 归因 | 无 | asa_flutter_plugin(Apple Search Ads 归因) |
| 账号迁移 | 无 | android_account_migration(专门的安卓账号迁移插件,见第七章) |
从这张表能读出一个明显的趋势:flutter_riverpod 在原生能力集成的丰富度上明显超过 flutter_getx(多了人脸 SDK、IDaaS 认证、专业埋点 SDK、广告归因、账号迁移),这和它使用更新的 Riverpod + GoRouter 组合是同期发生的——技术栈升级往往伴随着整个工程基建的一次系统性补强,不只是换了个状态管理库这么简单,这也是一个值得在面试里主动提及的观察。
三、屏幕适配:一个取巧但好用的 ScreenUtil 技巧
两个项目在 main.dart 里对 ScreenUtilInit 的 designSize 用了同一个耐人寻味的设置:
return ScreenUtilInit(
designSize: const Size(375, 100), // 设置高度为100,方便使用10.h代表屏幕高度10%,还原设计稿响应式单位应使用.w
...
);
flutter_screenutil 正常用法是把 designSize 设成设计稿的真实宽高(比如 Size(375, 812)),.w/.h 分别按宽度、高度的缩放比例换算。这两个项目故意把高度写成 100,效果是 .h 不再代表"设计稿某个高度值缩放后的结果",而是直接变成"屏幕高度的百分之几"——比如 10.h 就等于屏幕可用高度的 10%。宽度维持 375(主流设计稿宽度)不变,.w 仍按正常宽度缩放逻辑使用。
面试回答:
这是团队对
flutter_screenutil的一个取巧用法:把设计尺寸的高度维度直接设成 100,让.h从"设计稿高度换算"变成"屏幕高度百分比",写"占屏幕高度 30%"这类布局需求时可以直接写30.h,不用先手算设计稿上对应的像素值再换算。宽度维度保持正常设计稿宽度,.w该怎么用还是怎么用,两个单位在同一套库里承担了不同语义。用的时候要提醒团队:项目里.h不是字面意义上的"设计稿高度缩放",新同事第一次看容易理解错,最好在项目里有一处集中说明。
四、状态管理实战对比:从真实迁移案例讲起
这是这两个项目里最有面试价值的部分——flutter_riverpod 的授信模块下,OCR 身份证识别页面和活体检测页面原本是用 GetX 写的,后来被重新用 Riverpod 实现了一遍,团队自己写了迁移说明文档(lib/src/pages/credit/ocr/OCR_MIGRATION.md 和 lib/src/pages/credit/liveness/LIVENESS_MIGRATION.md),这意味着不需要凭空对比两个框架的优劣,手上有一份"同一个页面、两种实现"的第一手对照材料。
两份迁移文档给出的原始对照表
特性 | GetX 版本 | Riverpod 版本
状态管理 | GetxController + Obx | StateNotifierProvider + ConsumerWidget
响应式 | Obx() | ref.watch()
弹框处理 | Get.bottomSheet() / showDialog() | showDialog() + PopScope/Navigator
路由跳转 | Get.back() / Get.back(result) | Navigator.pop() / Navigator.pop(context, result)
依赖注入 | Get.find() | ref.read()
生命周期 | onInit / onClose | initState / dispose
代码复杂度 | 中等 | 简单
类型安全 | 一般 | 更好
用真实代码印证这张表
flutter_getx 登录页的 GetX 写法(lib/pages/login/logic.dart + state.dart):
// state.dart
class LoginState {
RxBool isChecked = false.obs;
RxBool commitBtn = false.obs;
RxString phone = "".obs;
}
// logic.dart
class LoginLogic extends GetxController {
final LoginState state = LoginState();
void onPhoneInput(String value) {
state.phone.value = state.phone.value + value; // 直接可变赋值
state.commitBtn.value = state.phone.value.length == state.maxLen;
}
void showPrivacyDialog() {
Get.bottomSheet(PrivacyDialog(onAgree: () {
state.isChecked.value = true;
Get.back(); // 全局单例式调用,不依赖当前 context
getCode();
}));
}
}
flutter_riverpod OCR 页面的 Riverpod 写法(来自迁移文档里给出的核心实现):
final ocrProvider = StateNotifierProvider<OcrNotifier, OcrState>((ref) {
return OcrNotifier(ref);
});
class OcrCardUpload extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final ocrState = ref.watch(ocrProvider); // 显式订阅,依赖关系写在 build 里
final ocrNotifier = ref.read(ocrProvider.notifier);
// ...
}
}
两段代码对照着看,正好能讲清楚 GetX 和 Riverpod 最核心的差异:GetX 的 .obs 变量可以在任何地方直接改(state.phone.value = ...),依赖靠 Get.find()/单例模式在运行时查找,不需要 BuildContext;Riverpod 的状态必须通过 Notifier 的方法去改(不能在 UI 里直接改 Provider 的值),依赖关系通过 ref.watch/ref.read 显式声明,build 方法一眼就能看出这个组件依赖了哪些状态。
面试回答:
我们在
flutter_riverpod这个项目里,把授信流程里的 OCR 识别和活体检测这两个页面从 GetX 迁移到了 Riverpod,并且写了迁移文档记录下来。核心动机是这两个页面涉及的状态比较复杂(识别结果、重试次数、加载状态、SDK 回调结果都要维护),GetX 的.obs变量可以在任意地方直接修改,页面稍微复杂一点就容易出现"某个状态在哪个文件里被改的"不好追踪的问题;Riverpod 强制所有状态修改都要经过Notifier的方法,配合不可变的State类,状态的修改路径是收敛的,排查问题时更容易定位。
诚实处理"迁移是否彻底完成"这个追问
两份迁移文档里都留有 TODO 清单,包括 SDK 集成(Flutter Face SDK 完整对接)、Mars 埋点、风控 token 管理这几块,写文档的时候这几项标的是未完成。面试时如果被追问"这个迁移做完了吗、上线了吗",不要含糊回答"做完了"或者不清楚现状就一口咬定,按实际情况说清楚进度更稳妥——如果后续确认已经补齐并上线,就按完整迁移来讲;如果还有 SDK/埋点部分没跟上,更适合把这个经历讲成"为什么优先把状态管理层迁移完、UI 和交互保持不变,再逐步补齐 SDK 对接"这个技术决策本身,而不是讲成一次已经完全收尾的迁移。
五、路由对比:GetX 命名路由 vs GoRouter 声明式路由
flutter_getx 的路由表(lib/router/app_pages.dart)是一个 GetPage 数组;flutter_riverpod 的路由表(lib/src/router/app_router.dart)是声明式的 GoRoute 数组,且直接把埋点接进了路由层:
class AppRouter {
static final router = GoRouter(
initialLocation: AppRoutesConfig.splash.path,
observers: RangersApplogNavigationObserver.wrap([]), // 路由切换自动上报,不用每个页面手动埋点
routes: [ /* ... */ ],
);
}
GoRouter 的 observers 接了一个 RangersApplogNavigationObserver,页面跳转的埋点变成路由层自动处理,业务代码里不需要在每个页面手动调用埋点方法;flutter_getx 那边埋点还是靠业务代码里手动调用 Mars.trackPageView 这类方法。这是路由方案切换带来的一个实打实的工程收益,不是纸面上的优势。
冷启动白屏问题:一个真实踩过的坑
flutter_riverpod 路由配置里有一行让人印象深刻的注释:
// !经验证,必须将 / 作为闪屏页的path,路由对象放置在数组首位,
// 否则安卓冷启动会导致无法匹配路由卡在白屏页面
这是一个具体的、可以直接在面试里讲的踩坑经历:Android 冷启动时,如果闪屏页不是路由表里的第一个、或者路径不是根路径 /,会出现路由匹配不上、页面卡在白屏的问题。这类问题只有真正杀死进程后冷启动才能复现,热重载和正常跳转都测不出来,最终解法是把闪屏页固定成根路径、且放在路由数组第一位。
六、Hybrid WebView:桥接协议与底层定制(重点)
这是两个项目里工程深度最高的部分,尤其是 flutter_riverpod——它没有用 flutter_inappwebview 的官方发布版本,而是把插件源码整个 vendor 进了仓库(plugins/flutter_inappwebview/),并且对底层做了实打实的定制,不是简单的参数配置。
定制一:把默认的 JS 桥接对象改名成 window.ppmoney
flutter_inappwebview 默认往页面里注入的桥接对象名字是 flutter_inappwebview(官方文档里常见的 window.flutter_inappwebview.callHandler(...))。vendor 进来的插件源码里,这个名字被直接改成了 ppmoney——Android、iOS、macOS、Windows 四个平台实现里都能找到同一处改动:
// flutter_inappwebview_android/.../JavaScriptBridgeJS.java
public static final String JAVASCRIPT_BRIDGE_NAME = "ppmoney";
配合公司另一个仓库 micro-loans-new(这两个 App 共用的 H5 前端项目)里的桥接调用代码能确认这一点:
// micro-loans-new/src/utils/sdk/call-native.js
class Bridge {
static _callAndriod(strJson) {
window.ppmoney && window.ppmoney.callNative && window.ppmoney.callNative(strJson)
}
static _callIOS(strJson) {
window.webkit?.messageHandlers?.callNative?.postMessage(strJson)
}
}
这里有一个很值得讲的细节:Android 和 iOS 走的根本不是同一套通道。Android 调用的是被改名后的 window.ppmoney(定制过的 flutter_inappwebview 插件桥);iOS 调用的是 window.webkit.messageHandlers.callNative,这是 WKWebView 原生自带的 WKScriptMessageHandler 机制,绕开了插件本身的桥接实现。H5 这一层把两个平台的差异完全屏蔽掉了——H5 代码只管调用统一的 Bridge.call(),具体走哪条通道由 Bridge 类内部按平台分流,H5 业务代码完全不需要关心底层通道差异。
面试回答:
我们把
flutter_inappwebview默认注入的桥接对象名字改成了业务名ppmoney,而不是用插件默认名字。这套 H5 桥接代码是跨多个 App 复用的(micro-loans-new里能看到按 App 区分环境的判断),如果桥接对象名字直接暴露"底层用的是哪个开源插件",以后换 WebView 方案时 H5 那一层理论上也要跟着改;统一成一个业务自己的命名,H5 只认这一个名字,原生这边不管底层怎么换实现,只要保证还是往页面注入一个叫ppmoney的兼容对象,H5 完全不用动。iOS 那边我们没有走插件桥,而是直接用 WKWebView 原生的messageHandlers机制,这两个通道在 Bridge 层被统一封装成一个调用入口,H5 侧完全感知不到平台差异。
定制二:修复 H5 用 history.replaceState 时的返回问题
vendor 插件的 git 记录里有一条实打实的 bug 修复:
916de23 fix: 跳过http的shouldOverrideUrlLoading处理,解决h5替换历史记录返回的问题
对应到 flutter_riverpod 里的 WebView 配置:
initialSettings: InAppWebViewSettings(
bypassHttpHttpsUrlLoading: true, // 绕过 http/https URL 拦截(影响 shouldOverrideUrlLoading 钩子)
// 解决 js replace url 页面栈无法被替换问题
// issue: https://github.com/pichillilorenzo/flutter_inappwebview/issues/1884
...
),
shouldOverrideUrlLoading 默认会拦截 WebView 里所有导航请求(包括普通 http/https 跳转),拦截后原生代码要决定放行还是自己处理。H5 侧如果用 history.replaceState 在不产生新导航记录的情况下"替换"当前页面地址,一旦被这个拦截钩子插一脚,WebView 自身维护的历史栈行为就可能和 H5 预期的不一致,导致用户点返回键表现不对。解法是让普通 http/https 导航完全绕过这个拦截钩子,只保留对自定义 scheme(比如原生要单独处理的 deeplink)的拦截,把常规网页导航交还给 WebView 原生的历史管理机制。
面试回答:
我们遇到过 H5 页面用
history.replaceState操作前端路由后,用户点返回键行为不符合预期的问题,排查后定位到shouldOverrideUrlLoading钩子把普通 http/https 导航也一并拦截了,和浏览器原生的历史栈管理产生了冲突。这个问题还对应到插件仓库一个公开 issue,我们的解法是给普通网页导航开一个绕过拦截的选项,只保留对自定义 scheme 的拦截,让常规导航完全交给 WebView 原生处理。
定制三:为什么关掉 useHybridComposition
useHybridComposition: false,
// 关闭混合合成模式;开启后会创建独立的安卓原生视图层,
// 通过 Surface 与 Flutter 渲染层混合,页面切换/动画时容易掉帧
useHybridComposition 是安卓平台特有的权衡:开启后 WebView 用独立原生视图层渲染、和 Flutter 渲染层通过 Surface 合成显示,好处是兼容性更好(尤其老版本安卓一些 WebView 渲染异常问题),代价是每帧多一步合成计算,页面切换动画容易掉帧。两个项目都选择关闭它,说明团队更看重路由切换流畅度,愿意为此承担部分老设备上的兼容性风险——这是一个典型的"性能 vs 兼容性"取舍。
定制四:多实例 WebView 与 Riverpod family 模式
flutter_riverpod 的 WebView 页面用 instanceId 区分不同实例:
InAppWebView(key: ValueKey('inappwebview_$instanceId'), ...)
WebViewNotifier get notifier => ref.read(webViewProvider(instanceId).notifier);
webViewProvider(instanceId) 是 Riverpod 的 family 用法——同一个 Provider 定义,按传入参数各自维护一份独立状态。业务场景是:App 内可能同时存在多层 WebView(从一个 H5 页面跳到另一个 H5 页面、原生页面栈叠了好几层 WebView),每一层都需要独立的加载状态、下载状态、截屏监听,不能互相污染。这是 Riverpod 相比 GetX 更自然的一个能力——GetX 也能用 tag 实现类似隔离,但 Riverpod 的 family provider 在类型和参数绑定上更直接。
定制五:面向合规场景的下载拦截与截屏监听
WebView 里专门做了 APK 下载拦截(APK_INTERCEPTION_SUMMARY.md),覆盖三种触发方式:直接 <a> 链接点击(shouldOverrideUrlLoading 拦截)、fetch() 下载(shouldInterceptFetchRequest 拦截)、以及对应的 blob 下载兜底(onDownloadStartRequest),三种方式最终收敛到同一套原生确认交互,不让浏览器行为各自为政。页面初始化时还会启动截屏监听(notifier.addScreenshotObserver())——金融类 App 常见的合规动作,用户在展示身份信息、合同条款、放款记录这类敏感页面截屏时能被感知并做相应处理。
桥接协议的两个不对称设计
结合 H5 侧代码(call-native.js / call-web.js),桥接协议还有两个值得单独讲的细节:
- 调用即自动上报:H5 侧
Bridge.call()每次发起调用(bury埋点事件本身除外,避免自己触发自己)都会自动带一次Mars.interActivePost('interactiveCall_v33', {...})上报,回调结果返回时再上报一次。桥接调用链路本身自带可观测性,业务代码调用桥接方法时完全不用操心埋点。 - 原生调 H5 比 H5 调原生简单得多:H5→原生走结构化的"类型+事件+回调函数名"协议(见下方"类型+事件"分发),原生→H5(
call-web.js)只是简单挂几个函数到window.ppmoney_web_handler上:
// call-web.js,原生可以直接 evaluateJavascript 调用这几个函数
window.ppmoney_web_handler = {
goToCredit: goToCredit,
isBindCardAfterGrey: isBindCardAfterGrey,
}
这个不对称是合理的:H5 调原生的场景很多、难穷举,需要一套通用协议;原生主动调 H5 的场景明确、有限,不需要额外抽象一层协议,直接暴露函数更直接。
桥接协议是"类型 + 事件"两级分发
真实的桥接协议比"一个 handler 名字对应一个处理函数"更结构化,分成 type(大类:uiLogic/getSomething/operation/routes)和 event(具体动作)两级:
static Map<String, Function> eventMap = {
'uiLogic': handleUiLogic, // 界面逻辑,如设置标题、隐藏返回按钮
'getSomething': handleGetSomething, // H5 向原生查询信息(用户信息、定位、权限状态)
'operation': handleOperation, // H5 触发原生动作(登录、退出、打开相册、批量申请权限)
'routes': handleRoutes, // H5 控制原生路由跳转,包括跳到 OCR/活体检测页
};
handleRoutes 分支特别值得讲:liveness 和 ocr 这两个路由值会被特殊处理,直接跳到原生实现的活体检测和 OCR 页面,检测完成后再把结果通过回调传回 H5——能用 H5 快速迭代的部分留在 H5,涉及设备能力/SDK/合规采集的部分必须是原生实现,桥接层负责让这两部分无缝切换。
七、flutter_riverpod 独有的工程亮点
这几个点只存在于 flutter_riverpod,是这个项目相比 flutter_getx 明显更"重"的地方,也是最能体现工程深度的素材。
从旧原生 App 迁移登录态和隐私协议授权状态
android_account_migration 这个本地插件专门解决一个具体问题:flutter_riverpod 是从一个更早的原生安卓 App 重写而来,如果不做处理,老用户升级到 Flutter 版本后会被强制重新登录、重新同意隐私协议——对信贷类 App 来说,这种体验断层会直接影响老用户的留存。插件的做法是:
- 读取 Android 原生应用 SharedPreferences 里的账户数据(userGid、userid、userToken 等)
- 同时迁移隐私协议授权状态
- 只在首次启动、且 Flutter 侧没有登录数据时执行迁移,避免覆盖新数据
- 迁移失败不影响应用正常启动(静默降级,不阻塞主流程)
- 迁移成功后打标记,避免重复执行
面试回答:
flutter_riverpod是从原生安卓 App 重写过来的,为了不让老用户升级后被迫重新登录、重新同意隐私协议,我们写了一个专门读取原生 SharedPreferences 的迁移插件,把登录态和隐私协议授权状态平移过来。这里的关键设计是幂等和降级:只在"首次启动 + Flutter 侧确实没有登录数据"时才执行,避免覆盖掉已经存在的新数据;迁移失败也不能影响 App 正常启动,只是这次没读到旧数据而已,用户大不了重新登录一次,不能因为这个迁移逻辑本身出错导致 App 打不开。这是一个典型的"存量 App 重写为 Flutter"过程中会遇到的真实问题,不是新项目会遇到的。
SDK 初始化管理器:把"隐私授权后才能初始化"收口到一个入口
SdkInitManager 是一个真实存在、直接对应《11-Flutter面试知识整理》里"隐私合规"那一章讲的原则的实现:
/// SDK初始化管理器
/// 统一管理所有需要隐私授权后才能初始化的SDK
class SdkInitManager {
static SdkInitManager get instance => _instance ??= SdkInitManager._internal();
/// 隐私授权后统一初始化所有合规SDK
/// 这是唯一的入口点,确保只在隐私授权后调用
Future<void> initSdkList({bool showLoading = true}) async {
if (isAllSdkInitialized) return; // 幂等,避免重复初始化
await _initializeMars(); // 火山上报 SDK
if (Platform.isAndroid) {
await ChannelInfoUtil.initialize(); // 安卓在闪屏页获取投放渠道编码
}
await AliLoginHelper.initialize(); // 一键登录初始化
if (showLoading) {
await Future.delayed(const Duration(milliseconds: 500)); // 见下方说明
Loading.dismiss();
}
}
}
这段代码的类注释直接写着"这是唯一的入口点,确保只在隐私授权后调用"——所有需要采集设备信息的 SDK(统计上报、一键登录、渠道归因)初始化逻辑全部收口到这一个方法里,业务代码只要在"用户同意隐私政策"这一个时机点调用一次 initSdkList(),不需要在各处分别判断"这个 SDK 能不能初始化了"。
其中有一个非常具体、真实踩过的坑:
// 等待500ms,避免阿里云一键登录SDK的activity启动失败
await Future.delayed(const Duration(milliseconds: 500));
面试回答:
我们把所有需要隐私授权才能初始化的 SDK(统计上报、一键登录、渠道归因)收口到一个
SdkInitManager里,只在用户同意隐私政策后调用一次,避免各处代码分别判断授权状态、容易漏判。这里踩过一个具体的坑:阿里云一键登录 SDK 初始化后如果立即弹出对应的 Activity/加载态切换,会出现 Activity 启动失败,最后的解法是在初始化流程里加一个 500 毫秒的延迟再继续。这类第三方 SDK 的时序问题通常没有官方文档能查到,是实际集成过程中试出来的,遇到"偶发概率性失败"且和某个原生 SDK 初始化相关时,值得怀疑是不是时序竞争问题,用适当的延迟去规避是一个务实但有效的手段。
一键登录与渠道归因:平台差异要单独处理
AliLoginHelper(阿里云号码认证服务,即"一键登录",通过运营商网关免密获取手机号,不需要用户输入验证码)和渠道归因(ChannelInfoUtil、asa_flutter_plugin)都体现了同一个模式:Android 和 iOS 处理时机不一样,不能用一套逻辑硬套两个平台——代码里能看到明确的平台分支:安卓在闪屏页直接拿渠道编码;iOS 那边渠道编码的更新时机依赖 ASA(Apple Search Ads)归因是否已经上报过,如果还没上报,会退回到在原生首页获取渠道编码。这类"同一个业务概念,两个平台的数据获取时机天然不同"的处理经验,也是 Hybrid/跨端开发里经常被面试问到的实际问题。
八、风控与合规相关的原生能力
授信流程本身涉及实名认证和风控数据采集,两个项目里能看到具体对应的字段和插件:
riskControlUserGid、fkToken(风控 token):桥接协议里 OCR 和活体检测的跳转参数都带着这两个字段,说明风控系统需要知道"这次身份核验是哪个风控用户 ID 发起的",核验结果要能关联回具体的风控评估流程。getAntiFraudInfo(获取反欺诈信息)、getAuthorization(批量查询相机/日历/通讯录/定位/麦克风权限状态):桥接协议里专门有这两类接口,说明 H5 侧需要能主动查询设备的风控相关信号,这和《11-Flutter面试知识整理》第十二章讲的"免权限/需权限风控字段分类"是同一套业务逻辑在具体项目里的落地。flutter_jailbreak_detection(越狱/root 检测,flutter_getx)、flutter_face(人脸识别 SDK,flutter_riverpod):分别对应"设备环境是否可信"和"活体检测本身"这两类风控能力。SdkInitManager统一收口的隐私合规 SDK 初始化(第七章):确保风控/统计相关的数据采集只在用户同意隐私政策后才开始,这是合规原则在工程上的直接落地,不是只停留在文档层面。
九、面试怎么讲这两个项目
开场框架
先讲业务,再讲技术,不要一上来就讲技术栈:这是两个独立的消费信贷产品,核心流程都是登录、实名认证(OCR + 活体检测)、授信、账单管理,中间大量业务页面用 H5 承载,通过原生 Hybrid 桥接把设备能力(相机、定位、权限、风控数据)暴露给 H5。两个产品各自独立演进,一个用 GetX,一个用 Riverpod + GoRouter,后者是团队后来引入的更规范的方案,并且从一个更早的原生 App 重写而来,因此还额外做了账号迁移、SDK 初始化合规收口这类"存量产品重写"特有的工程工作。
准备好的具体追问应对
| 追问 | 应对要点 |
|---|---|
| 为什么两个项目技术栈不一样 | 两个独立品牌产品,不是同一个 App,技术栈是各自项目独立选型演进的结果 |
| GetX 和 Riverpod 具体差在哪 | 用第四章的真实代码对照讲:.obs 可变直接赋值 vs Notifier 方法收敛修改路径 |
| 迁移是不是完全做完了 | 按实际进度如实说明,没做完就讲成"为什么优先迁移状态管理层"这个技术判断 |
| 屏幕适配怎么做的 | ScreenUtilInit 把 designSize 高度设成 100,让 .h 变成"屏幕高度百分比"而不是"设计稿高度缩放"(第三章) |
| Hybrid 桥接怎么设计的 | 类型+事件两级分发,H5 能反向控制原生路由跳到 OCR/活体检测页(第六章) |
| 为什么把桥接对象改名叫 ppmoney | H5 团队维护同一套代码服务多个 App,桥接对象名字统一成业务名而不暴露底层插件,换底层实现时 H5 不用跟着改;iOS 走的是 WKWebView 原生 messageHandlers,和 Android 通道不同,但对 H5 透明(第六章) |
| 为什么关闭 useHybridComposition | 性能与兼容性的取舍,关闭后路由切换更流畅,代价是部分老设备渲染兼容性风险(第六章) |
| 遇到过什么具体问题 | 冷启动路由白屏(第五章);H5 history.replaceState 导致返回行为异常,靠 bypassHttpHttpsUrlLoading 解决(第六章);一键登录 SDK 初始化时序问题,靠 500ms 延迟规避(第七章) |
| 涉及风控/合规的经验 | riskControlUserGid/fkToken 关联风控流程、权限状态批量查询接口、SdkInitManager 统一收口隐私合规 SDK 初始化(第七、八章) |
| 存量 App 重写为 Flutter 有什么特殊经验 | 账号迁移插件:读取原生 SharedPreferences 迁移登录态和隐私协议授权状态,幂等 + 失败降级不阻塞启动(第七章) |
需要自己补充的部分
这份笔记的素材全部来自代码本身能验证的信息,两个产品各自具体的业务规模(DAU、放款量级)、团队分工(这两个项目是不是同一个人从头到尾负责)、迁移工作具体花了多久、迁移后线上问题率有没有变化这类数据性的内容,代码里看不出来,需要自己补充真实数字——面试里有具体数字支撑的项目介绍,比只讲技术选型更有说服力。