1. 适配背景与核心思路
这阵子我在做一款 Flutter 应用向鸿蒙端迁移的方案验证,后端数据层正好用的是 Supabase。项目里十几个表、几十个 RPC 函数,如果全手写模型类、手写查询构造器,维护成本高得离谱。所以我第一反应是把 supabase_codegen 拉起来,用数据库 schema 直接生成强类型 Dart 代码。本来以为只是加个 dev dependency 跑一下命令的事,结果在鸿蒙工程里折腾了大半天。今天就把适配过程、踩坑点、以及最后跑通的方案完整记录下来。
先捋一下目标:Supabase 本身是开源的 BaaS 方案,底层数据库是 PostgreSQL,提供 REST API、Realtime、Auth、Storage 这些能力。Flutter 生态里对应的客户端是 supabase_flutter / supabase 这两个 Dart 包。supabase_codegen 的定位则是“代码生成器”,它读取数据库的 schema 信息,自动生成强类型的表模型、查询构造器和 RPC 调用封装。简单说,你手里本来只有一张 users 表,跑完 codegen 之后,Dart 代码里就能直接写出supabase.from(UsersTable()).select().eq(User_.name, "张三")这种带补全、带类型检查的代码,字段拼错会在编译期直接报错。
鸿蒙化适配的核心难点不在 codegen 本身,而在“链路”:codegen 需要连接数据库拿到元数据,需要在本机跑 Dart 命令,生成后的代码又要在鸿蒙 Flutter 引擎里跑起来。大部分问题出在两端:一是工具链依赖的包不一定兼容 OpenHarmony 的 Flutter 插件桥接层,二是接口权限、网络权限、Linux 路径约束这些容易被忽略。所以这篇文章会拆成“原理层 - 环境层 - 实操层 - 排错层”四部分,适合正在做鸿蒙应用、或者准备从 Android/iOS 迁移到鸿蒙的 Flutter 开发者参考。
2. supabase_codegen 的工作原理与“强类型自动化映射”到底好在哪
2.1 三条核心链路
supabase_codegen 之所以能“自动映射”,不是靠黑魔法,而是靠三条链路协作:
第一条是数据库元数据读取。它会连接你的 Supabase 项目,读取 information_schema / pg_catalog 里的表结构、字段类型、默认值、外键关系、枚举类型、视图、函数签名。这相当于把 PostgreSQL 的“自我介绍”转成一份中间结构。
第二条是 Dart 代码模板生成。拿到中间结构之后,codegen 把每一张表映射成一个 model 类,把字段类型映射成 Dart 类型,比如int8变成int,timestamptz变成DateTime,jsonb变成dynamic或Map<String, dynamic>。同时生成对应的表查询类、数据变更方法、RPC 封装。这里的关键是有很多特殊的可空性、默认值、自增主键逻辑,Codegen 用它自己的规则去处理,比人手写稳定得多。
第三条是运行时绑定。生成代码里包含了SupabaseClient的原生查询方法,而不是自己拼 URL。比如:
final result = await supabase .from(UsersTable()) .select() .eq(User_.status, "active");UsersTable()和User_.status都是生成出来的常量。这样你写查询的时候,IDE 能自动提示表名和字段名,数据库里哪个字段改了,重新生成一下代码,工程里所有依赖它的地方会一起编译报错,强迫你把相关逻辑改干净。
2.2 强类型映射的价值
很多人觉得“不就是多生成几个类吗,有什么了不起”。实际用起来差异非常大。手写模型时代,我经常犯这类错误:把created_at记成create_at,或者把is_admin当布尔值传,结果后端存的是int2。这类问题在运行时才暴露,往往是线上数据都写坏了才发现。强类型映射把这类错误直接前移到编译期。
再一个是 RPC 函数。Supabase 支持你写 PostgreSQL 函数,比如get_user_orders(user_id uuid)。手写调用时候要传参数名、参数类型,拼错一个就 400。supabase_codegen 会把函数签名也算出来,生成一个rpcGetUserOrders之类的函数,参数类型完全由数据库签名决定,对接成本低很多。
还有一个被低估的点:字段重命名。数据库里的last_login_at想改成last_seen_at,如果手写模型,你得全局搜替换;走了 codegen,只需要把数据库 schema 改了,重新生成,所有引用旧字段的代码会直接编译不过。这种方式倒逼你维护“数据库 - 代码”的一致性,长期看非常舒服。
2.3 鸿蒙化适配的技术坐标系
鸿蒙的 Flutter 支持走的是 OpenHarmony 的 Flutter 引擎路线,Dart runtime 本身是跨平台的,所以纯 Dart 包一般能直接跑。问题集中在“平台通道”和“原生插件”。supabase_codegen 是纯 Dart 命令行工具,按理说跟鸿蒙没有直接关系;但生成代码后,如果运行时用的是 supabase_flutter 全家桶,它内部会依赖shared_preferences、url_launcher、app_links这些插件。这些插件在鸿蒙上如果没有对应实现,轻则flutter pub get时报错,重则运行时找不到平台通道。
适配策略通常分两种:一是让 supabase_flutter 链路里的每个插件都在鸿蒙侧找到替代实现;二是绕开全家桶,用核心supabase包 + 自定义的本地存储实现。我实际测试下来,第二种更省心,尤其是你只想用数据库和 RPC,不用 Supabase 的 Auth 邮箱验证等功能时。
3. 动手之前:环境准备与版本选型
3.1 工具链要求
先说环境。我用的组合是:
- Flutter SDK:3.16 版本以上(带 Dart 3)
- OpenHarmony SDK / HarmonyOS NEXT 开发者预览版
- Supabase 服务端:本地 Docker 版或云端项目都可以
- Dart 包管理器:pub
flutter --version dart --version建议在 pubspec.yaml 里把 supabase_codegen 放在 dev_dependencies:
dependencies: supabase: ^2.0.0 dev_dependencies: supabase_codegen: ^1.0.0先别急着把 supabase_flutter 加进来,等确定鸿蒙插件链路没问题再补。我个人教训是:一开始想省事直接上全家桶,结果 pub 依赖解析阶段就卡住了,好几个插件在 OpenHarmony 平台没有发布包,只能手动 fork。
3.2 数据库 schema 准备
codegen 的前提是数据库结构足够规范。建议先做一次 schema 整理,把字段命名统一为snake_case,主键统一为uuid或bigint,时间字段带默认值。因为 codegen 的映射规则对命名和类型很敏感,字段名乱七八糟会导致生成的 Dart 标识符非法。
我一般先在本地用 Supabase CLI 起一个开发实例:
supabase init supabase start然后写supabase/migrations/下的 SQL 脚本建表。数据库起来之后,可以用supabase db diff导出 schema SQL,后面 offline 场景用得上。
3.3 鸿蒙工程结构
鸿蒙上的 Flutter 工程,除了android/、ios/、web/这些传统平台目录,还会有ohos/目录。这个目录里是鸿蒙原生的工程结构和配置文件,比如module.json5、entry/src/main/ets/。Flutter 引擎在鸿蒙上通过一个项目模板集成,Flutter 插件则需要有对应的鸿蒙平台的 TS/ETS 实现。所以你在引入任何 Flutter 插件之前,最好先确认它在 ohos 端有没有被桥接。
排查方法很简单,看.flutter-plugins-dependencies文件里有没有ohos平台的 plugin 映射。如果有,说明生态兼容;如果里面没有,但你又确实需要这个插件,就得自己走“鸿蒙平台通道”适配的路子。
4. 核心适配步骤:从 schema 到生成 Dart 代码
4.1 初始化配置
supabase_codegen 的配置通常放在supabase.yaml里,我的配置长这样:
supabase_url: http://127.0.0.1:54321 service_role_key: your-service-role-key output_dir: lib/database generate_rpc: true generate_views: truesupabase_url指向本地或云端的 Supabase API 地址,service_role_key用来获取元数据。这里有一个安全层面要特别注意:service_role_key权限极高,千万别提交到公开仓库。建议把supabase.yaml加进.gitignore,或者用环境变量占位。
然后执行:
dart run supabase_codegen:generate首次运行会看到类似这样的输出:
Fetching database schema... Fetching RPC functions... Writing lib/database/database.dart Writing lib/database/tables/users.dart Writing lib/database/rpc/get_user_orders.dart4.2 生成结果解读
生成的文件通常分成几类:
| 文件 | 内容 |
|---|---|
database.dart | 全局的 SupabaseClient 扩展入口,聚合所有表与 RPC |
tables/*.dart | 每张表对应的模型类、表常量、查询封装 |
rpc/*.dart | 每个数据库函数对应的强类型调用封装 |
types/*.dart | 枚举、复合类型、自定义类型的 Dart 映射 |
比如针对一张 users 表:
class User { final String id; final String name; final String? email; final DateTime createdAt; const User({ required this.id, required this.name, this.email, required this.createdAt, }); factory User.fromJson(Map<String, dynamic> json) { return User( id: json['id'] as String, name: json['name'] as String, email: json['email'] as String?, createdAt: DateTime.parse(json['created_at'] as String), ); } Map<String, dynamic> toJson() => { 'id': id, 'name': name, 'email': email, 'created_at': createdAt.toIso8601String(), }; }这个类就是强类型映射的最终产物。后面在业务代码里我做数据展示时,几乎不再接触原始Map<String, dynamic>,所有字段都有 IDE 提示。
4.3 绕过全家桶插件链路
到了鸿蒙这一层,最关键的一步是确认工程依赖满足要求。我把依赖收敛成:
dependencies: supabase: ^2.0.0 web_socket_channel: ^2.4.0 http: ^1.1.0 uuid: ^3.0.7然后把所有涉及原生插件的代码隔离到一个单独的storage服务里。因为supabase包需要一个地方保存登录态 token,我默认不依赖 supabase_flutter 的SharedPreferences,而是用纯 Dart 的文件读写 +path_provider的鸿蒙适配版。
如果确实想保留下原生插件链路,思路是找到每个插件的 ohos 实现,比如用兼容性的shared_preferencesfork,但这就要求你维护额外的 overrides:
dependency_overrides: shared_preferences: git: url: https://github.com/example/shared_preferences_ohos.git ref: main这种方案能跑,但每次升级 Flutter 版本都要重新验证兼容性。我建议能不用就不用,数据存储这层自己包一层接口,后面无论是换localstorage还是换 SQLite 都很顺。
4.4 接入鸿蒙工程的最后一步
生成代码本身不直接触碰鸿蒙 API,它只是普通 Dart 代码。真正要处理的是网络权限和证书。
如果你在真机调试,使用http://内网地址去连本地 Supabase,需要在鸿蒙工程配置里加网络权限。找到ohos/entry/src/main/module.json5,确认有:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }如果连的是线上 HTTPS 接口,一般不用额外处理。但本地开发用的是 HTTP,就可能被系统网络策略拦下来,现象就是SocketException或者Connection refused。别在 Dart 层疯狂改代码,先查平台权限。
最后把生成的lib/database目录提交进工程,同时在main.dart里初始化:
void main() { final supabase = SupabaseClient( 'https://your-project.supabase.co', 'your-anon-key', ); runApp(MyApp(supabase: supabase)); }到这里,一个能跑在鸿蒙 Flutter 引擎里的强类型数据处理链路就通了。
5. 常见问题与排查实录
5.1 本地开发连不上 Supabase
症状:跑dart run supabase_codegen:generate时报连接超时或 401。
排查路径:先确认 Supabase 本地实例有没有起来,supabase status看一下 API 和 Postgres 端口;再检查service_role_key是不是当前项目的密钥,别把anon_key复制上去。还有一种情况是公司内网代理把 54321 端口屏蔽了,这时候直接用云端临时项目跑 codegen,或者把 schema SQL 导出到本地文件,配合 codegen 的离线 schema 模式。
5.2 生成的代码在鸿蒙上运行时类型转换失败
症状:type '_Map<String, dynamic>' is not a subtype of type 'List<Map<String, dynamic>>'。
这种通常是 codegen 版本和运行时supabase包的查询返回结构不一致。比如旧生成器把.select()返回结果当List<Map<String, dynamic>>,但新版本supabase会返回List<dynamic>。解决办法不是手改生成代码,而是把 supabase_codegen 和 supabase 升级到匹配的大版本,然后重新生成。我曾吃过一次亏,手动在模型层转了半天的类型,后来发现是包版本不一致。
5.3 枚举类型没有自动生成
Supabase 里的CREATE TYPE枚举,codegen 不一定默认生成。需要在配置里显式开启 types 生成,有些版本还需要你在supabase.yaml里列出需要导出的 schema:
types: - public如果开了还是没生成,检查数据库角色能否访问 pg_type 表。权限不足时 codegen 拿不到枚举定义,自然生成不出来。这时候与其去猜,不如把登录用户换成postgres角色再跑一次。
5.4 鸿蒙真机上请求被拦截
症状:代码没问题,但supabase.from(UsersTable()).select()一直报网络错误。
这个我排查得最久。一开始以为 DNS 问题,后来发现是鸿蒙的网络权限没加。默认工程模板不一定包含INTERNET权限,必须要手动在module.json5里加。如果用 HTTP 调试,还会遇到明文流量限制。这种情况要么改用 HTTPS 代理,要么在鸿蒙网络安全配置里临时放行调试域名。
5.5 pub 依赖冲突
症状:flutter pub get报shared_preferences版本冲突,或者url_launcher平台不支持。
这套问题基本都出在全家桶插件上。我的建议是先把supabase_flutter从依赖里拿掉,只保留supabase核心包。然后给网络、存储、深链这些能力单独接鸿蒙实现。如果你想省事,可以先把依赖全部升级到最新版本,再看.flutter-plugins-dependencies里是否识别到了鸿蒙平台,识别不到就说明当前插件没有 ohos bridge 配置。
5.6 代码生成结果在热重载后不刷新
有时候你调整了数据库 schema,重新跑 codegen,但 IDE 里代码还是旧的。别慌,这是 build 缓存问题。用:
dart run build_runner clean dart run supabase_codegen:generate如果改动频繁,建议把 codegen 命令写进脚本,和数据库迁移脚本绑定在一起执行。不然很容易出现数据库结构变了、生成代码没跟上,前端就莫名其妙报错。
6. 端云一体化数据处理体系的落地建议
6.1 仓库层与服务层分离
代码生成出来了,但不意味着可以直接在 Widget 里到处调supabase.from(...)。我习惯再包一层 Repository:
class UserRepository { UserRepository(this._db); final SupabaseClient _db; Future<List<User>> fetchActiveUsers() async { final data = await _db .from(UsersTable()) .select() .eq(User_.status, "active"); return data; } }这样业务层不感知 database 细节,未来如果要换本地数据库做缓存,或者要加权限过滤,都是在 Repository 这一层做。supabase_codegen 生成的强类型模型可以跨层复用,但查询语句最好收敛到一个数据访问层。
6.2 Realtime 与本地状态结合
端云一体离不开实时数据。Supabase Realtime 底层是 WebSocket,在鸿蒙 Flutter 里我用了web_socket_channel连接,运行得很稳。配合上强类型模型,收到推送后直接解析成 Dart 对象,再丢给状态管理框架更新 UI。
订阅代码大概长这样:
final channel = supabase.channel('public:users'); channel.onPostgresChanges( event: PostgresChangeEvent.all, schema: 'public', table: 'users', callback: (payload) { final newUser = User.fromJson(payload.newRecord); // 更新本地列表 }, );这里有一个经验:不要把所有表的变更都订阅,鸿蒙真机上长连接多了很耗电。按需订阅当前页面需要关注的表,离开页面时及时channel.unsubscribe()。
6.3 增量生成与包体积控制
如果你的数据库表特别多,比如几十上百张表,codegen 会把全部模型都生成出来,这会让包体积变大,编译时间也明显增加。可以在配置里用included_tables或exclude_tables来控制范围。我习惯只生成“真实业务表”,一些日志表、内部关联表用手写模型替代。
included_tables: - users - orders - products生成的代码里也会带一些不必要的辅助类,建议在构建阶段开--tree-shake优化。对鸿蒙包体积本来就紧张的场景,过滤表 + 压缩混淆能省不少空间。
6.4 与 RLS 权限控制配套使用
强类型映射解决的是开发效率,不等于安全。Supabase 建议数据库层开启 Row Level Security,所有客户端请求都要经过 RLS 策略过滤。使用 codegen 生成代码时,它只会忠实反映 schema 结构,不会帮你判断某条策略对不对。所以我把生成代码当作“数据访问的语法层”,真正的权限判断依然放在 PostgreSQL 里。两层各司其职,开发和安全性都不牺牲。
7. 最后的实际操作体会
这套适配做完,我最大的感受是:codegen 这类工具,真正的投资回报不在生成代码量,而在“约束一致性”。只要数据库 schema 一改,整个 Flutter 端所有不匹配的引用都会在编译期爆出来。这个收益,在跨端场景尤其明显,不用再靠人工维护 Android、iOS、鸿蒙三份手写模型了。
最后再分享一个小技巧:我跑 codegen 的时候不会直接指向生产数据库。先本地起一套 Supabase 容器,把迁移脚本在里面跑完,确认 schema 正确后才生成代码。生成完顺手在本地例子上做冒烟测试,全部通过再提交。这样既不会把半成品 schema 暴露到生产环境,又能在出问题时快速回滚。鸿蒙端的 Flutter 项目现在越来越多人在做,工具链也在快速完善,但数据这一层早点走上强类型自动化,后面会省很多事。