做了几年 Flutter 跨境支付,MercadoPago 这个名字应该不陌生:拉美市场的“微信支付+支付宝”,巴西、阿根廷、墨西哥这些国家的电商基本绕不开它。我们团队负责的拉美业务线,早期在 Android 和 iOS 上就用 Flutter 接入了官方维护的 mercadopago_sdk,整体体验很顺。但今年开始把 App 往鸿蒙上迁移时,麻烦来了:这条路没法直接复用,官方没有鸿蒙 SDK,Flutter 端的三方库也不会凭空跑在鸿蒙设备上,于是就有了这篇鸿蒙化适配的记录。
我把整个适配过程拆成方案选型、ArkTS 原生插件实现、Flutter 端兼容层改造、结算安全与常见坑五个部分。适合三类人读:一是正准备把 Flutter 插件移植到鸿蒙的开发者,二是需要在鸿蒙上接入拉美支付的业务方,三是对鸿蒙跨端方案选型感兴趣的同学。整篇没有用什么高深框架,核心就是一个 Flutter 插件如何在鸿蒙上用 ArkTS 重写原生层,同时把原本的 SDK 调用接口尽量保持不变,让业务代码改动量压到最低。
1. 方案选型与整体架构
1.1 为什么不能直接把三方 SDK 搬过来
先说清楚 mercadopago_sdk 在 Flutter 里到底做了什么。它本质上是一个平台通道封装:Dart 侧调用MercadoPagoSDK.startPayment(),原生侧拉起 MercadoPago 的支付页面,再通过回调把结果返回给 Flutter。Android 端的底层依赖是 MercadoPago 官方的 Android SDK,那是一套 Kotlin 写的 AAR 库,内部还有资源文件、Activity 声明、AndroidX 依赖,这些都不可能直接搬到鸿蒙上。
有些人会想,鸿蒙不是兼容 Android APK 吗?能不能直接把原来的 APK 扔上去跑。这个思路在适配阶段确实能兜底,但代价很大:你要对整个 App 做兼容容器改造,支付这种高频、高资金安全敏感的场景,多一层解释转换就多一层不确定。而且 mercadopago_sdk 这个 Flutter 插件依赖 Flutter Engine 与原生侧的绑定,在兼容环境下 Flutter 的 PlatformChannel 能不能稳定工作,本身就要打问号。所以实战上最稳的路线就是:不碰原来的 Android 实现,在鸿蒙上用 ArkTS 重写原生侧,把 Dart 层保留下来。
1.2 两条可行的鸿蒙化路线:WebView 与原生 API
重写原生侧之前,要先想清楚一个问题:MercadoPago 的支付能力,到底通过什么方式在鸿蒙上落地。这里我对比了两条路线,可以看下面这张表。
| 方案 | 实现成本 | 定制能力 | 支付方式覆盖 | 稳定性 |
|---|---|---|---|---|
| ArkWeb 加载 Checkout Pro 页面 | 低,一周内可跑通 | 弱,样式固化 | 覆盖官方全部支付方式,包括 Pix、卡、转账等 | 依赖 WebView 和网络,回跳需要处理 |
| ArkTS 调 REST API 自建支付页 | 高,需要自己实现卡表单、验证码等 | 强,可以和业务 UI 完全融合 | 取决于自己接 API 的范围 | 需要完善的错误处理和状态轮询 |
我最终采用的是“双通道配合”:默认走 ArkWeb 加载 Checkout Pro 的init_point链接,因为拉美用户最熟悉的支付方式是 Pix 和本地卡,这些在官方支付页里已经做得很成熟;同时预留一个原生 API 通道,用于某些需要完全定制 UI 的业务场景。这样既保证了支付方式的覆盖面,也给业务侧留了后路。
为什么这么选?核心原因是资金安全。支付页面涉及卡号、CVV 这类敏感数据,官方托管页的 PCI 合规成本是最低的。如果你自建支付页,就需要自己去处理卡信息采集的合规问题,这不是一个民间团队轻易能扛下来的。我的经验是:能用托管页的场景尽量用托管页,只有业务强需求才走自建。
1.3 鸿蒙插件工程骨架怎么搭
方向定下来之后,就要搭鸿蒙侧插件工程。Flutter 插件对鸿蒙的支持,目前是通过在工程里增加ohos子目录来实现的,和原来 Android 的android目录、iOS 的ios目录是平行关系。一个典型的目录结构长这样:
my_flutter_plugin/ ├── lib/ # Dart 侧代码 │ └── mercadopago_sdk.dart ├── ohos/ │ ├── build-profile.json5 │ ├── oh-package.json5 │ └── entry/src/main/ │ ├── ets/ │ │ ├── MercadoPagoPlugin.ets │ │ ├── PaymentApi.ets │ │ └── PaymentWebView.ets │ └── module.json5 └── pubspec.yaml关键在ohos/oh-package.json5里声明对 Flutter 鸿蒙引擎的依赖,大致长这样:
{ "name": "mercadopago_sdk_ohos", "version": "1.0.0", "description": "MercadoPago SDK for HarmonyOS", "main": "index.ets", "dependencies": { "@ohos/flutter_ohos": "^3.22.0", "@ohos/webview": "^1.0.0" } }依赖的版本号要和你项目用的 Flutter 版本对齐。这里有个经验:鸿蒙的 Flutter 适配是通过独立的 SDK 分支发布的,版本命名可能和 Flutter 官方版本号不完全一致,我第一次就直接复制了一个旧项目的依赖版本,结果编译报接口找不到。不要盲目用最新版,优先看 flutter_ohos 和你当前 Flutter 版本配套的 release 说明。
build-profile.json5里则需要把插件模块声明为动态库或者静态库,并配置签名文件,这部分和普通鸿蒙应用工程类似。如果是初次跑通,可以直接参考 OpenHarmony 官方提供的 flutter 插件示例工程。
2. 鸿蒙原生侧核心实现:MethodChannel 与支付流程
2.1 先设计好 MethodChannel 协议
鸿蒙化适配的本质,就是要在 ArkTS 侧用 MethodChannel 原封不动地接住 Flutter 侧发来的调用。MethodChannel 这个名字听起来抽象,你可以把它理解成两端约定好的一套“快递单号”:Flutter 发一个字符串方法名和参数包裹,鸿蒙接收后拆包执行,再把结果寄回去。
我在设计协议时把方法名和参数定义做成了一张表,尽量和原 SDK 的语义保持一致:
| 方法名 | 参数 | 返回 |
|---|---|---|
| createPreference | order对象 | preferenceId、initPoint |
| startPayment | initPoint | 支付结果状态 |
| queryPaymentStatus | paymentId | 支付状态 |
| handleDeepLink | url | 是否已处理 |
| disposePayment | 无 | 是否成功 |
方法名不要随便起,整个 App 里可能有多个插件,命名越具体越不容易冲突。我见过有人用pay这种简单名字做通道方法,后来和另一个支付插件冲突,排查了半天。建议格式统一为域名/功能,比如mercadopago/createPreference。
2.2 创建支付偏好:Preference 的核心逻辑
MercadoPago 的支付流程第一步不是直接拉起支付页,而是先在后端创建一个 Preference。你可以把它理解成一张“购物单据”,上面写清楚了金额、商品描述、支付方式偏好、回调地址等信息。创建成功后返回一个init_point链接,这个链接就是用来打开支付页的。
在鸿蒙侧我用 ArkTS 实现了这个创建过程。核心代码如下:
import { http } from '@kit.NetworkKit'; async function createPreference(order: OrderPayload): Promise<PreferenceResult> { const request = http.createHttp(); const url = 'https://api.mercadopago.com/checkout/preferences'; const options: http.HttpRequestOptions = { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${order.accessToken}`, 'X-Idempotency-Key': order.idempotencyKey }, extraData: JSON.stringify({ items: order.items, payer: order.payer, payment_methods: order.paymentMethods, notification_url: order.notificationUrl, back_urls: order.backUrls, auto_return: 'approved' }) }; const response = await request.request(url, options); const result = JSON.parse(response.result as string) as PreferenceResult; request.destroy(); return result; }这段代码里有三个容易踩坑的点,我单独说。
第一,header 里必须传Authorization,而且用的是后端下发的 Access Token。这里有个安全问题:如果你在客户端直接写死长期有效的 token,一旦反编译泄露,资金风险不可控。我们的做法是每次通过 App 的业务后端换取短时 token,然后再下发给鸿蒙侧使用,这个 token 有效期通常只有几十分钟。
第二,X-Idempotency-Key是幂等键,很重要。MercadoPago 会在这个键相同时返回同一个 Preference,而不是重复创建。我们的业务后端在生成订单时会给每个订单生成一个唯一键,这样即使用户在网络抖动时重试,也不会产生重复单据。
第三,back_urls一定要配置。支付完成后用户需要回到 App,这个字段就是控制回跳地址的。我在auto_return里填了approved,这样只有当支付成功时才会自动回跳,如果支付 pending 或失败,就留在支付页让用户继续处理或者手动返回。
2.3 拉起支付页与支付状态的轮询机制
拿到了init_point之后,下一步就是打开它。我选择了 ArkWeb 来加载链接,同时给它挂一个返回值监听。加载页面的代码不复杂,难的是状态怎么回传。
ArkWeb 本身没法直接把页面里的 JS 数据同步到 Flutter,所以我们需要用回调 URL 来做中转。状态同步的链路是:支付页跳转到back_urls指定的 App scheme 或 universal link,鸿蒙侧通过onLoadIntercept捕获跳转,解析 URL 里的状态参数,再通过 MethodChannel 的 result 返回给 Flutter。
这里有一个支付状态轮询的问题需要说明。auto_return只会在支付被批准时触发,但拉美市场大量订单是pending状态,比如 Pix 支付需要等用户完成转账,或者有些银行会延迟确认。这种订单如果只依赖回跳,业务侧就永远拿不到最终结果。我的方案是加一个兜底轮询:
async function pollPaymentStatus(paymentId: string, maxAttempts = 10): Promise<PaymentStatus> { const url = `https://api.mercadopago.com/v1/payments/${paymentId}`; for (let i = 0; i < maxAttempts; i++) { const status = await queryPaymentStatus(url); if (status === 'approved' || status === 'rejected' || status === 'cancelled') { return status; } await sleep(5000); } return PaymentStatus.Pending; }轮询间隔我选了 5 秒,最多查 10 次。如果 50 秒后还是 pending,就不再轮询了,而是把pending状态返回给 Flutter,让业务侧显示“待支付确认”的页面。在拉美市场,Pix 支付的确认时间通常在几秒到几分钟之间,用户在页面看得到状态,体验还可以接受。不要无限轮询,既浪费流量,又会让 App 处于高频网络请求状态。
这里还有个小细节:轮询要用专门的 paymentId,而不是 Preference ID。Preference 是购物单号,payment 才是实际发生的这笔支付流水,两者是 1 对多的关系。我最早就用错了 ID,导致查出来的状态永远是 pending,排查了很久才发现是查错了对象。
3. Flutter 层的兼容改造与封装
3.1 让业务代码尽量零改动
鸿蒙侧做完之后,Flutter 层还要做一件事:让原有调用 mercadopago_sdk 的代码尽量少改。既然原生实现换了,Dart 类的内部实现当然要重写,但对外的方法签名要保持原样。这就好比商家换了供应商,但收银台的位置和支付按钮还保持不变,顾客不用重新学习怎么付款。
原来的业务调用长这样:
final sdk = MercadoPagoSDK(); await sdk.initialize( publicKey: publicKey, accessToken: accessToken, ); final result = await sdk.startPayment( orderId: order.id, amount: order.amount, description: order.description, );我在鸿蒙化之后的封装里,保留了initialize、startPayment、getPaymentStatus这三个主要方法。内部实现从直接调用原三方包的入口,替换成通过 MethodChannel 调鸿蒙侧。这样做的收益很直接:业务侧原来怎么调用,现在还是怎么调用,没必要为了适配鸿蒙而重写整条支付链路。
不过这里要提醒一句,Dart 侧的startPayment不再是一个“拉起原生界面后立即返回结果”的同步方法,它内部要经历创建 Preference、打开 WebView、等待回跳、必要时轮询这一整个流程。所以我把它做成了Future<PaymentResult>,在原生侧进入 WebView 时并不立即 complete,只有拿到回跳状态或者轮询出结果时才 complete。这也是 Flutter 异步编程里最常见的“一个 Future 跨平台等待”的模式。
3.2 用 EventChannel 实现状态与通知的回传
MethodChannel 适合一次请求一次响应的场景,但支付过程中还有一类信息是异步推送的,比如支付状态变化、WebView 加载进度、Pix 二维码生成事件等。这类场景如果再硬用 MethodChannel,就得在 Flutter 侧开一堆轮询的 Timer,很别扭。正确做法是用 EventChannel。
鸿蒙侧的 EventChannel 注册方法大致如下:
import { EventChannel } from '@ohos/flutter_ohos'; const channel = new EventChannel(engine, 'mercadopago/events'); const eventSink = channel.createEventSink(); // 支付状态变化时 eventSink.success({ event: 'statusChanged', paymentId: paymentId, status: 'approved' });Flutter 侧订阅时,只需要在initState里挂上监听:
_eventChannel = EventChannel('mercadopago/events'); _subscription = _eventChannel.receiveBroadcastStream().listen((data) { final event = Map<String, dynamic>.from(data as Map); if (event['event'] == 'statusChanged') { _handleStatusChanged(event['paymentId'], event['status']); } });使用 EventChannel 时有一个非常重要的 bind 时机问题。鸿蒙引擎在插件 attach 时就要完成 channel 的注册和准备,这样 Flutter 侧receiveBroadcastStream()才能在后台恢复时快速挂上。如果插件 attach 晚了,前半段事件就会丢掉。我在工程里把 EventChannel 的初始化放在onAttachToEngine里面,不要等到第一次调用支付时才初始化,这是踩过坑之后才改对的。
3.3 App 生命周期与支付页的恢复处理
鸿蒙的支付场景还有个躲不开的问题:用户可能在支付页停留很久,甚至切到别的 App 再回来。这时候 Flutter 侧可能已经被系统切到后台,App 进程也可能被回收。如果用户支付成功了,但 Flutter 侧还停留在上一个状态,那整个业务订单就僵住了。
我的处理思路是在鸿蒙原生侧记录“当前支付上下文”,包括 paymentId、preferenceId、initPoint 链接。当 Flutter 侧重新进入前台时,通过一个resumePaymentContext方法主动查询鸿蒙侧有没有未完成的支付上下文,有的话就根据当前 paymentId 调一次查询接口,把最新状态同步给业务层。
这个恢复机制的成本不高,但对用户体验影响很大。拉美用户的手机性能和网络环境参差不齐,App 进程被回收是常态。你永远不希望用户看到“支付成功”的页面在鸿蒙上出现,却因为进程重建丢掉了上下文。把上下文持久化到鸿蒙侧的内存缓存里,再加上一层业务后端的订单状态查询,双保险才靠得住。
4. 支付安全与结算对账实践
4.1 敏感数据的本地化约束
做支付相关开发,第一原则就是敏感数据不能落地。在鸿蒙侧适配时,尤其要注意 ArkTS 开发中常见的对象序列化和持久化习惯。卡号、CVV、Access Token 这类数据,只允许在支付页面所在的进程内存中短暂存在,用完立即释放,不要写日志,不要存数据库。
我见过有同事为了方便调试,直接把完整的请求报文和返回报文打到日志里,其中就有 Access Token。这在联调环境也许问题不大,但一旦上了生产,日志系统被外部看到,就是重大安全事故。鸿蒙侧做日志差分时,要增加一个过滤逻辑:所有包含authorization、token、card_number的字段,一律打码后再输出。宁可排查麻烦一点,也不能把敏感信息暴露出去。
另一个容易忽略的点是 WebView 缓存。Checkout Pro 页面里可能包含用户的部分支付数据,如果 WebView 开启了缓存,这些数据就可能落在磁盘里。我在适配时强制关闭了 ArkWeb 的 DOM 存储和表单缓存,页面销毁时再主动清理 WebView 数据目录。
4.2 回调签名验证:别信任任何外部跳转
支付流程里有一个关键的安全节点:外部跳转回 App 的时候。如果攻击者伪造一个back_url的 scheme 跳转,里面带上一个假的支付成功参数,而 App 直接采信这个参数,那么坏人就可以“免费购物”。这是支付集成中非常经典的漏洞。
正确的做法是:鸿蒙侧收到跳转回调后,只把它当作一个“提醒信号”,真正的支付状态必须从服务端查询,并且要验证 MercadoPago 的 Webhook 签名。在鸿蒙侧做不了完整的签名验证,因为验签需要的 secret 不能放到客户端。我们的方案是,鸿蒙侧把收到的回调通知透传给业务后端,由后端去 MercadoPago 查询最终状态并做落库。
同时,对 Webhook 通知本身,我们要求后端验证x-signature头。大致校验逻辑是拼接id和topic参数,再用 HMAC-SHA256 计算签名,和请求头里的签名比对。签名验不过的请求直接丢弃。这套逻辑在 MercadoPago 的文档里有说明,但很多团队接入时会忽略,这个环节一定不要省。
4.3 结算对账中的幂等与订单号设计
最后一个环节是财务结算。在鸿蒙端看起来只是“用户付钱了”这么简单,但到了真金白银的对账环节,问题就多了:重复支付怎么算?部分退款怎么同步?挂起订单什么时候最终确认?
我的经验是把整个结算系统建立在“订单号唯一”的基础上。业务后端在创建订单时生成全局唯一的order_id,在创建 Preference 时把它放进external_reference字段里。这样后续任何查询、对账、退款操作,都可以用external_reference反查到业务订单。MercadoPago 的 API 会原样返回这个字段,对账时按它聚合就可以了。
同时要处理好支付失败后的重试。用户的卡可能第一次被拒绝,然后换一张卡支付成功,这在本场景里就是两条 payment 记录,但只有一个订单。如果后端不做幂等,就可能把订单标记成两次成功,造成财务口径混乱。我建议在订单状态流转上加一个规则:只有当external_reference对应的订单从未绑定过approved的 payment 时,才允许新的支付成为成功支付;否则新支付直接按“重复支付”处理,自动走退款流程。
5. 常见问题与排查技巧实录
5.1 ArkTS 严格模式下的类型“翻译”问题
鸿蒙的 ArkTS 对 TypeScript 做了一定约束:普通对象必须显式声明接口类型,any类型在很多场景下会被禁止,动态给对象加字段也不行。从原本的 JS 写法迁移过来时,最常碰到的就是 JSON 解析出的对象无法直接当自定义类型用。
举一个实际例子:
// 不要这样写 let payment = JSON.parse(response.result as string); let status = payment.status; // 要这样写 let payment = JSON.parse(response.result as string) as PaymentResult; let status = payment.status;如果PaymentResult接口里没有声明status字段,ArkTS 编译器还是会报错。正确的做法是把所有 API 返回结构体,在 ArkTS 里预先用 interface 声明完整。这个过程枯燥,但也是把 TypeScript 项目迁移到鸿蒙时避不开的一步。
同时注意JSON.parse返回的是object | null,所以as转换前最好先做判空。我当时在这个地方吃过一次空指针的亏,后来写了个工具方法统一处理 JSON 解析,成功解析的返回强类型结果,解析失败的直接返回 null,节省了不少排查时间。
5.2 ArkWeb 与 Flutter 的渲染叠加问题
Flutter 在鸿蒙上运行时的渲染路径,从目前的方案来看,是走独立的渲染引擎。这本身就有一个历史问题:Flutter 的 Texture 控件和原生 WebView 叠加时,容易出现“WebView 被挡在 Flutter 控件后面”或者“上层页面卡在 Web 视图上面”的情况。
解决办法是把 ArkWeb 放进一个原生容器页面,而不是试图用某个 Flutter 控件去包住 WebView。鸿蒙侧插件在收到startPayment时,直接打开一个原生页面,页面上放一个占满屏幕的 ArkWeb,Flutter 侧只是等待这个原生页面的状态回调。这个体验接近 Android 上通过 Activity 拉起支付页的方式。
如果你用的 Flutter 版本开启了 Impeller 渲染引擎,遇到视觉异常的概率会更高。这不是说 Impeller 有问题,而是一个新渲染引擎在 PlatformView 能力上的成熟度还需要时间。如果线上反馈 WebView 显示异常,可以考虑在鸿蒙插件页面代码中调整 Texture 共享方式的配置,或者先回退到兼容模式验证是不是渲染引擎的锅。
5.3 网络请求异常:从 2300056 到证书问题
鸿蒙的网络框架和 Android 有差异,应用到 MercadoPago 请求时,最典型的现象是 Android 正常、鸿蒙报网络错误。我之前碰到过错误码 2300056,本质上是网络框架对连接复用或 TLS 配置的处理不一致。
排查这个问题时,我会习惯性先抓包看请求有没有出网,返回状态码是多少。如果确认请求已经到达服务器,那就基本排除网络权限问题,重点转向 TLS 版本和证书链校验。鸿蒙默认可能使用系统根证书,如果你用了自签名证书的沙箱环境,需要单独配置信任规则。但我必须提醒:生产环境不要随意放开证书校验,调试用临时配置,上线前必须关掉。
另外一个容易忽略的点是请求超时设置。拉美用户的网络状况波动大,我推荐把连接超时设到 15 秒以上,不然一个慢网络就把整个支付流程打断了。之前用默认 10 秒超时,在巴西一些地区频繁超时,后来调到 30 秒才稳。支付场景宁可让用户等一两秒,也不要轻易判定失败。
5.4 双端回归测试与灰度发布建议
鸿蒙化的支付链路涉及 Flutter、ArkTS 原生、MercadoPago 服务端三方,联调时最容易出现“某一端看着没问题,但整体就是不通”的情况。我的建议是在正式发布前维护一份端到端的自测清单,至少覆盖下面这些场景:
| 场景 | 预期结果 |
|---|---|
| 首次打开支付页 | init_point 正常加载,无白屏 |
| 支付成功并自动回跳 | Flutter 收到 approved 状态 |
| Pixel 支付 pending | 后端落库 pending,App 显示等待确认 |
| 支付页手动关闭 | Flutter 收到 cancelled/pending 状态 |
| 支付中切后台再恢复 | 能恢复上下文,或者通过后端查询兜底 |
| 网络断开时发起支付 | 有明确的失败提示,不闪退 |
| 重复点击支付按钮 | 不会创建多个 Preference |
灰度发布方面,由于 MercadoPago 本身区分 sandbox 和 production 环境,一定要先在 sandbox 环境中完整跑通所有 case,再切 production。这个顺序千万别颠倒。我见过直接把 production key 写在代码里做联调的团队,风险非常大,一旦泄露,别人就可以拿你的商户号去发起支付请求,亏的是自己。
我在实际适配中的几点体会
之前总觉得鸿蒙化适配只是一次跨端技术迁移,把 Dart 方法接到 ArkTS 上就行。真正做完之后才发现,支付 SD 这类涉及资金安全的库,难点不在平台通道怎么连通,而在如何把原 SDK 的安全模型、状态模型、结算模型在新平台上重新实现一遍。
我的第一个体会是,方案选型一定要在原 SDK 的行为边界内做文章。用 WebView 加载 Checkout Pro 是成本最低、覆盖最全的路线,如果一上来就想替代原 SDK 的所有能力,很可能陷入自建支付页的泥潭。第二,状态恢复和幂等设计不能等到上线后补,支付链路一旦在生产环境出问题,都是直接和钱相关的故障。最后,多预留一点时间做真机回归测试,尤其要在拉美主流机型的低端配置上跑一遍,鸿蒙系统在这类设备上的 WebView 和网络表现会更接近最终用户的真实环境。