
先说结论ferry_generator 这套 GraphQL 代码生成链在鸿蒙化项目里是能用的但绝对不是你换一个 target、跑一遍flutter build就自动出活。我最近在帮团队把一套基于 Flutter GraphQL 的业务客户端往鸿蒙侧迁移最卡人的不是 UI 适配反而是数据层这一整套强类型生成和运行时缓存的东西。越是看起来“纯 Dart 应该没问题”的库踩坑越深。这篇文章就围绕 ferry_generator 的鸿蒙化适配从生成器工作原理、鸿蒙侧的依赖改造、数据层自动化的落地方式到我实际踩过的几个坑一次说清楚。适合正在做鸿蒙 Flutter 改造、又被 GraphQL 代码生成折磨的移动端开发也适合想了解 ferry 系工具链在非 Android/iOS 平台能不能站住脚的同学。1. 为什么鸿蒙项目里的 API 层会卡在 ferry_generator 上1.1 从 Flutter 到鸿蒙运行时第一个撞墙的往往是依赖我们通常说 Flutter 跨平台其实跨的是 Android、iOS、Web 这一批官方平台。鸿蒙这边走的是 OpenHarmony 的 Flutter 适配引擎很多 Flutter 插件需要单独提供 ohos 平台的实现否则编译期就报 Plugin not found。真正的问题在于ferry_generator本身是纯 Dart 写的 build 插件理论上不碰平台通道但它生成出来的代码要跑起来依赖的是ferry运行时库而ferry背后又挂着gql、gql_exec、gql_link、hive、http这一整条链。这条链里只要有一个库在鸿蒙适配层有短板你的 API 请求就发不出去。我在迁移第一天遇到的就是hive在鸿蒙设备上打开数据库时路径拿不到的问题。Hive 默认会去当前工作目录找文件但鸿蒙应用运行时的工作目录和 Android 沙箱目录完全不是一回事直接Hive.init()会在设备上报FileSystemException。这个坑不解决后面代码生成跑得再顺也没用。1.2 ferry 系工具链在鸿蒙侧的实际兼容情况我评估过 ferry 相关的底层依赖有一个粗略的兼容性表你可以直接拿来当参考依赖库是否纯 Dart鸿蒙侧风险点实测结论ferry是依赖 gql 缓存和 hive本身无平台代码可作为运行时库使用ferry_generator是纯构建期工具不参与运行时可直接使用gql / gql_exec / gql_link是HttpLink 依赖 http 包风险较低http是底层 socket 由 Flutter engine 提供鸿蒙适配引擎已覆盖hive是dart:io路径语义不同需要手动指定目录需调整初始化方式path_provider有 ohos 社区实现获取沙箱路径建议使用这套表里最值得盯住的就是hive。ferry 的缓存体系在 0.14 之后默认用NormalizedCache内存缓存为主如果你上了HiveCache做持久化就必须处理好鸿蒙的路径。1.3 这篇指南适合谁如果你是纯 Flutter 开发对鸿蒙还不熟你需要先接受一个事实鸿蒙侧的项目结构多了一个ohos目录工程构建最终走的是 HAP 打包和 Android 的 Gradle 完全是两回事。如果你刚好是鸿蒙原生开发但对 GraphQL 不熟那你需要先理解 ferry 到底帮你省了什么你不再手写 DTO、不再手写 JSON 解析、不再手写请求参数的 Map 拼接这些都是生成器在编译期替你完成的。不管哪类读者建议先花半天把 ferry 的数据流画明白.graphql文件进build_runner 生成强类型代码运行时通过 ferry client 发请求缓存命中后 UI 自动刷新。这条线清楚了再往下看实操才不会晕。2. 先搞懂生成器在做什么ferry_generator 的代码生产流水线2.1 build_runner 与 Ferry 的生成契约ferry_generator 不是独立运行的它挂在build_runner的构建体系里。你需要在pubspec.yaml里声明ferry、ferry_generator和build_runner然后在工程里放.graphql文件执行dart run build_runner build --delete-conflicting-outputs这个命令会扫描项目里所有.graphql文件并根据你配置的 schema可以是schema.graphql文件也可以是.json生成对应的 Dart 代码。很多人在这一步卡住提示Missing required option或者Invalid schema原因通常是工程里同时存在多个 schema 来源build_runner 不知道用哪一个。最佳实践是只在graphql/目录下放一份 schema所有 operations 文件也放同一个目录路径越短越好排查。2.2 一个 .graphql 文件能长出多少层代码以一个简单的用户查询为例query GetUser($id: ID!) { user(id: $id) { id name avatarUrl posts { title } } }跑完生成器之后你会看到同一目录下多出好几个文件生成文件用途get_user.graphql.dart操作类定义声明 Query / Mutation / Subscriptionget_user.req.dart请求封装类帮你拼好变量和操作名get_user.data.dart强类型数据类对应 GraphQL 返回结构get_user.data.gql.dart供json_serializable与part使用的辅助文件get_user.ast.gql.dartGraphQL AST 相关的构建器其中类名会按约定展开GetUser操作会生成GGetUserQuery、GGetUserQuery$Data、GGetUserQueryReq。Req后缀类里的vars就是你的请求参数强类型约束传错类型会在编译期报错。我见过不少项目把Req类又手动包了一层数据类完全没有必要生成出来的GGetUserQuery$Data就是你 UI 层要用的最终数据形态。2.3 强类型生成的核心GraphQL 类型系统如何映射为 Dart 类型GraphQL 的标量类型和 Dart 类型不是一一对应的ferry_generator 内部有默认映射表GraphQL 类型Dart 类型IntintFloatdoubleBooleanboolStringStringIDString自定义标量默认String可配置fromJson/toJson自定义标量是很容易埋雷的点。比如后端把日期字段定义为DateTime标量而你没有在ferry_generator配置里声明它的序列化方式生成出来的字段就是String你后续到处做DateTime.parse反而把强类型优势丢了。正确的做法是在build.yaml里给自定义标量指定类型映射targets: $default: builders: ferry_generator: options: scalars: DateTime: type: DateTime fromJson: DateTime.parse toJson: (_) _.toIso8601String()2.4 为什么编译期生成类型比运行时手写解析可靠之前有人问我现在 JSON 解析库那么多手写 DTO 也不麻烦为什么非要引入生成器。差别就在 GraphQL 的空安全上。GraphQL 里字段默认可空手写解析时很容易漏判null一个字段漏判就是线上崩溃。生成器会把每个字段的可空性精确映射成 Dart 的 nullable / non-nullable比如user(id: ID!)里如果 schema 定义User不可空生成出来的user字段就是GGetUserQuery$Data$User不是可空的。编译期就把隐患掐死。更重要的是GraphQL 查询可以嵌 fragment生成器会为每个 fragment 生成顶级数据类并在任何引用了它的操作里安全展开。这意味着你把查询结构调整了生成代码也会跟着变不需要手改解析逻辑。这个优势在手写解析时代是无法想象的。3. 鸿蒙化适配的硬骨头依赖替换、编译目标与本地缓存3.1 先把工程切到 ohos 平台目录鸿蒙 Flutter 工程的目录结构和安卓不一样通常需要在工程根目录下有一个ohos目录里面是 DevEco Studio 工程。如果你的项目是从普通 Flutter 工程迁移过来的而不是直接flutter create --platforms ohos生成的需要检查.metadata里的平台列表是否包含 ohos。这一步如果省略后面flutter build hap或鸿蒙侧的编译命令会直接报平台不支持。我在实际操作中建议先用 DevEco Studio 新建一个最小的 Flutter/HarmonyOS 工程模板再把业务代码和 pubspec 依赖逐一迁过去比手动改目录配置靠谱得多。3.2 pubspec 里的依赖版本怎么选这是最容易出兼容问题的地方ferry 的版本和gql系列是强绑定的。我用的组合是dependencies: ferry: ^0.14.0 gql: ^0.14.0 gql_exec: ^0.4.4 gql_link: ^0.5.0 normalize: ^0.7.0 hive: ^2.2.3 path_provider: ^2.1.0 dev_dependencies: ferry_generator: ^0.8.0 build_runner: ^2.4.8 json_serializable: ^6.7.1这里有两个坑。第一个是normalize的版本必须和gql匹配否则运行时会报type NormalizedCache is not a subtype这种隐蔽错误编译期完全查不出来。第二个是http包的版本gql_link的 HttpLink 对 http 包有上限约束如果工程里其他依赖把它顶到 1.x而gql_link还是按 0.13 的逻辑写的就会在运行时出现Unsupported operation。我的处理办法是统一用http: ^1.2.0并在dependency_overrides里锁死dependency_overrides: http: 1.2.0锁版本看着粗暴但在鸿蒙这种本来就不是官方首发的平台上稳定优先。3.3 Hive 缓存和底层 I/O 的鸿蒙差异ferry 的持久化缓存主要靠HiveCache而 Hive 在初始化时需要用到一个可写的本地目录。Android 上通常用getApplicationDocumentsDirectory()或者PathProvider拿路径鸿蒙侧建议统一用final dir await getApplicationSupportDirectory(); Hive.init(dir.path);如果不做这一步Hive 会尝试在当前目录创建文件。在鸿蒙的应用沙箱机制下这个目录要么不存在要么只有只读权限你会看到和下面类似的堆栈FileSystemException: Cannot open file, path ... (OS Error: Permission denied)还有一点Hive 初始化必须在FerryClient创建之前完成否则 ferry 创建缓存实例时会直接找不到 Hive 的TypeRegistry。我建议把缓存的初始化放在 main 函数入口最前面和 WidgetsFlutterBinding 的初始化并列避免后续操作依赖未知状态。3.4 build_runner 在鸿蒙工程里的运行技巧ferry_generator 生成代码时会在工程内做文件监听。普通开发机没问题但如果你在 CI 容器或者 Windows 上跑会遇到Unable to watch file之类的报错尤其是建了ohos目录之后工程文件数量比纯 Flutter 项目多不少构建效率会下降。有两个实用命令# 常规本地开发 dart run build_runner build --delete-conflicting-outputs # CI 环境内存受限只生成指定目录下的 graphql 相关文件 dart run build_runner build --delete-conflicting-outputs --build-filter lib/common/graphql/**.graphql.*--delete-conflicting-outputs一定要带上ferry 多次生成时某些旧文件不会自动清理不删会导致类重复定义。如果你遇到Required input is missing检查一下.graphql文件是不是放在被build_runner忽略的目录里比如.dart_tool或ohos目录生成器默认扫描的是lib下的源码目录。3.5 验证清单编译过、生成过、请求通代码生成完成后我习惯按下面这套清单走一遍比直接启动 App 快得多检查生成文件是否完整get_user.data.dart里是否包含预期嵌套类执行flutter analyze确认没有 type mismatch跑一轮鸿蒙侧编译确认没有插件缺失真机启动后先走一个不依赖缓存的 Query确认 HttpLink 能通杀掉进程重开确认第二次请求能命中本地缓存。这五步全部通过说明 ferry_generator 的鸿蒙化适配基本落地了。4. 数据层自动化实战让生成的代码从“能编译”到“真正接管业务”4.1 不要让业务层再包一层 DTO我见过最浪费的做法ferry 已经生成了GGetUserQuery$Data业务层又手写一个UserModel再写 up 和 down 两个转换函数。这不仅把强类型生成的优势抹掉还让响应式缓存失效因为 ferry 的缓存基于生成类型做归一化你一旦转成自己的 DTO缓存里的数据类型就对不上了。正确设计是UI 层和 Repository 层直接消费生成类型。只有需要把数据传给鸿蒙原生侧或者跨 isolate 传递时才在边界做一次转换其他地方禁止出现二次 DTO。4.2 用 TypedLink 缓存策略组装请求管线ferry 客户端标准初始化长这样final client FerryClient( link: TypedLink( link: HttpLink(https://api.example.com/graphql), cache: NormalizedCache(), ), cache: NormalizedCache(), defaultRequestPolicy: RequestPolicy.cacheAndNetwork, );TypedLink的作用是让请求和响应自动套上生成类型省掉所有手写序列化。cacheAndNetwork的策略是先返回缓存数据给 UI同时在后台发起网络请求等网络响应回来后更新缓存并触发 UI 刷新。这个策略最符合移动端体验首屏秒开数据依然新鲜。发请求的时候直接用生成的Req类final response await client .request(GGetUserReq((b) b..vars.id userId)) .first; if (response.hasErrors) { // 统一错误处理 } final user response.data?.user;想监听后续缓存更新用watchQuery替代request它会返回一个 Stream任何归一化缓存里和该查询相关的数据变化都会推给 UI。4.3 schema 与 operation 的工程规范决定了自动化上限生成器再强也是基于 schema 和 operation 文件工作的。实际项目最容易犯的错是把整个后端 schema 一次性拉进来几十 MB 的schema.graphql放进工程每次生成都慢而且任何字段变更都可能引爆编译错误。我建议只把当前客户端真正用到的 fragment 和 query 对应的 schema 子集放进来和schema.graphql放在一起。同时给每个 operation 写清楚命名像GetUser、UpdateUserAvatar这种语义化名字生成出来的类名可读性才高。如果你命名成Q1、U1生成代码之后看的人会崩溃。4.4 与 Provider 结合的状态管理示例ferry 的生成代码和状态管理框架的配合重点是让 Repository 作为唯一数据源。我用flutter provider举个例子先建一个基于ChangeNotifier的仓库class UserRepository extends ChangeNotifier { UserRepository(this._client); final FerryClient _client; GGetUserQuery$Data? _user; String? _error; bool _loading false; Futurevoid loadUser(String id) async { _loading true; _error null; notifyListeners(); try { final response await _client .request(GGetUserReq((b) b..vars.id id)) .first; _user response.data?.user; } catch (e) { _error e.toString(); } finally { _loading false; notifyListeners(); } } }Provider 这边很简单ChangeNotifierProvider( create: (_) UserRepository(ferryClient)..loadUser(123), child: UserPage(), )UI 里直接读_user的字段类型是生成出来的强类型不需要任何 JSON 序列化操作。如果需要列表页、详情页共享同一个用户对象就在 Provider 上层注册同一个UserRepository实例配合watchQuery后端数据更新时 UI 会自动变。5. 我踩过的适配坑和性能调优记录5.1 坑一part 文件与循环导入ferry 生成的文件里有一类.data.gql.dart会被主数据文件用part引入。如果你为了让目录整洁手动把这些文件拖到其他文件夹或者用 IDE 的移动重构很容易出现part指令和文件实际位置不一致进而引发Cycle import或Not found part。请一定保持生成器输出的目录结构不要手动整理生成文件。真觉得乱应该在.graphql文件的目录规划阶段就分好模块而不是生成之后做搬运。5.2 坑二鸿蒙侧依赖冲突鸿蒙工程里如果有其他三方库也依赖 http、dio 或者 hive很容易出现版本冲突。ferry 这条链对版本极敏感尤其是gql_exec和gql_link版本不同步时运行期会报NoSuchMethodError。我遇到一次比较隐蔽某个功能库强制依赖hive: 2.0.1而 ferry 需要2.2.3。Dart 的解析器并不会报错只是两个版本同时被引用最终 Hive 的 box 类型不匹配数据读不出来。最后还是在dependency_overrides里统一锁定 hive 到 2.2.3 解决。鸿蒙适配期不要怕用 override稳定运行比洁癖重要。5.3 坑三GraphQL 枚举生成后的未知分支后端 GraphQL enum 会被生成成带扩展的字符串类型类似class UserRole { static const admin UserRole(admin); static const user UserRole(user); }如果后端上线后新增了一个枚举值比如moderator而你客户端的 switch 没有覆盖运行期就一定会走默认分支。不要把unknown分支直接 throw最好在 UI 上做成降级展示或者打点上报让问题浮出来而不是崩掉。5.4 性能调优缓存命中与请求去重ferry 在cacheAndNetwork策略下缓存命中率已经很高但列表页多接口并发时仍然可能对同一个查询发出重复请求。可以在 Repository 层加一个简单的 in-flight map同一个 operation 同一组参数在请求进行中时后续调用直接复用同一个 Futurefinal MapString, Futuredynamic _inflight {}; FutureOperationResult _dedupe(String key, FutureOperationResult request) { final existing _inflight[key]; if (existing ! null) return existing.castOperationResult(); final future request.whenComplete(() _inflight.remove(key)); _inflight[key] future; return future; }注意 key 别只用 operation 名要把变量用到的字段也拼进去比如GetUser:$id否则参数不同也会被错误去重。5.5 上线前的 GraphQL 层自检清单最后一套自查项照着过一遍基本能避免上线后才暴露问题检查项说明schema 是否与后端当前版本一致不一致时生成代码可能是“能编译但字段过时”Hive 是否完成初始化未初始化则缓存层静默失败是否有手写 DTO 转了生成类型有则缓存失效需要重构错误分支是否覆盖网络异常GraphQL errors 和网络异常是两回事鸿蒙特有路径是否用 path_provider不允许硬编码目录版本冲突是否已用 dependency_overrides 处理运行时版本错乱会非常隐蔽就我个人经验鸿蒙化的适配难点从来不在 ferry_generator 本身而在于我们太习惯 Android/iOS 的现成假设。路径、权限、沙箱、插件实现每换一个平台都要重新审视一遍。如果你也在做这个方向的迁移别急着让生成器跑通先把 ferry 的运行时链路在鸿蒙真机上用最简单的一条 query 打通再逐步加上缓存、Provider、复杂嵌套查询这样每一步出问题都能定位。