Flutter 三方库 flutter_email_sender 的 OpenHarmony 鸿蒙化适配指南(mailto RFC 6068 + 静态 Context 转发实战)

Flutter 社区地址: https://atomgit.com/CPF-Flutter/flutter_flutter
github三方库地址:https://github.com/sidlatau/flutter_email_sender
pub地址:https://pub.dev/packages/flutter_email_sender
鸿蒙适配版:https://atomgit.com/oh-flutter/email_sender

库版本:flutter_email_sender v10.0.x(上游 master,pub workspace 联邦三包重构版)|验证环境:Flutter 鸿蒙 SDK 3.44.9(oh-3.44.9-dev)|DevEco Studio 26.0.0.821 | DevEco 模拟器 + 真机 HarmonyOS 7.0.0.105(API 26)

在 Flutter 应用里,发送邮件是高频基础能力——账号注册验证、订单反馈、客服会话一键发送、诊断日志回传都依赖它。flutter_email_sender 是 pub.dev 上专注于"调用系统邮件 composer"的 Flutter 插件(区别于 emailjs 这类走 SMTP 协议直发的方案),但它在 OpenHarmony 上没有任何官方实现。本文记录我把它完整迁移到鸿蒙的全过程。
在这里插入图片描述

这次适配的难点集中在一个很"底层"的差异上:Android 的 Intent 有一套 putExtra 体系,主题、正文、收件人可以挂在 extras 上由系统分发给邮件应用;而鸿蒙的 Want 跨应用传参最可靠的载体就是 uri 一个字符串。所以鸿蒙侧不能照搬 Android 的思路,必须把整封邮件"编码"进一个符合 RFC 6068 的 mailto: URI,再用 startAbility 的隐式 Want 拉起系统邮件应用。同时还有两个工程问题:插件在引擎侧拿不到 UIAbilityContextstartAbility 是它的专属能力),以及 Dart facade 侧的平台守卫会把 ohos 直接判成"无邮件能力"。下面逐层展开。
在这里插入图片描述
在这里插入图片描述

一、环境搭建

本章不重复展开,直接引用官方文档:Flutter OH 开发环境搭建指导

本文实际使用版本:Flutter OH oh-3.44.9-dev、DevEco Studio 26.0.0.821、HarmonyOS SDK API 26,flutter doctor -v 相关两项 [√] 即可开工。

二、应用背景

2.1 场景与痛点

  • 账号验证邮件:注册流程末尾"给客服发验证邮件",正文带用户名和时间戳
  • 订单反馈:订单详情页"反馈问题"按钮,主题自动带上订单号
  • 客服入口:设置页"联系我们",收件人固定、主题预填
  • 邀请分享:邀请好友注册,正文带邀请码
  • 一键诊断反馈:把设备信息、日志摘录拼进正文,用户点一下就进系统邮件 composer

痛点:这些场景的共同特征是应用自己不碰 SMTP——不收集用户的邮箱账号密码、不申请网络权限直发,只负责"把字段预填好,剩下的交给系统邮件应用"。自研方案要在鸿蒙上自己拼 Want、处理邮件应用不存在的兜底、处理 URI 编码,适配工作量集中在"把 Dart 侧的 Email 模型无损地搬进 Want"这一步。

2.2 为什么需要这个库

发邮件在 Flutter 生态里有两条路线:

路线代表库体验适合
SMTP 直发emailjs 等应用内静默发送,但要用户配置邮箱账号/授权码,体验重后台自动化发信
系统邮件 composerflutter_email_sender拉起系统邮件应用预填字段,用户确认后发送,体验轻用户可见的反馈/客服场景

flutter_email_sender 屏蔽了 Android ACTION_SENDTO / iOS MFMailComposeViewController / macOS 的平台差异,业务侧只构造一个 Email 对象(subject / body / recipients / cc / bcc / isHTML / attachmentPaths)然后 await FlutterEmailSender.send(email)。它还提供 getCapabilities() 让 UI 按平台能力动态降级(比如 macOS 不支持 cc/bcc)。库在上游已完成联邦化(federated plugin)重构,这对鸿蒙适配是好消息——只需在 flutter_email_sender_method_channel 包里新增 ohos 平台实现,app 面的 API 一个字都不用改。

2.3 解决什么问题

一句话总结:让 Flutter 应用在鸿蒙上以与 Android/iOS 完全一致的 Email 模型调用 FlutterEmailSender.send(),内部把整封邮件编码为 RFC 6068 mailto URI,通过 startAbility({action: viewData, uri}) 拉起系统邮件 composer,收件人/主题/正文/cc/bcc 全部预填

能力映射表(对照 Android 原版实现逐项翻译):

flutter_email_sender 能力Android 原版鸿蒙适配
canSendMail 探测resolveActivity(ACTION_SENDTO, mailto:)ArkTS getCapabilities 返回 canSend:true,探测兜底下沉到 send 失败路径
send(无附件)ACTION_SENDTO + mailto: + EXTRA_SUBJECT/EXTRA_TEXT/EXTRA_EMAIL/...startAbility + ohos.want.action.viewData + mailto URI(字段编进 query 参数)
send(带附件)ACTION_SEND + FileProvider URI + EXTRA_STREAM当前版本未透传(mailto 无附件语义,见 FAQ Q7)
HTML 正文HtmlCompat.fromHtml + EXTRA_HTML_TEXTisHTML\n → <br> 后编入 body= 参数
Activity/Ability 注入ActivityAware.onAttachedToActivityEntryAbility.onCreate 静态 setContext(this.context) 转发
发送结果回调startActivityForResult + onActivityResultstartAbility Promise then/catch

三、接口分析(适配前必做)

3.1 联邦三包结构

上游 master 已是 pub workspace monorepo,根 pubspec.yamlworkspace: packages/* 聚合:

flutter_email_sender/                       # 仓库根(workspace)
├── packages/
│   ├── flutter_email_sender/                # app 面主包
│   │   └── example/                        # demo(含 ohos/ 调试工程)
│   ├── flutter_email_sender_platform_interface/  # 纯 Dart 接口包
│   │   └── lib/src/{email.dart, email_capabilities.dart, ...}
│   └── flutter_email_sender_method_channel/     # 平台实现包
│       ├── android/  ios/  macos/  ohos/   # ← ohos 是本次新增
│       └── lib/src/method_channel_flutter_email_sender.dart
└── pubspec.yaml                            # resolution: workspace
  • platform_interface:定义 Email(不可变请求模型)、EmailCapabilities(能力集)、FlutterEmailSenderPlatform(抽象类 + instance 单例)。纯 Dart 不碰平台,无需改动。
  • method_channel:各平台的 MethodChannel 实现,Android/iOS/macOS 原生实现都在这个包里,ohos 实现也放这里。
  • 主包:只做 default_package 声明,把平台路由到 method_channel 包。

3.2 MethodChannel 协议

method_channel_flutter_email_sender.dart 在名为 flutter_email_sender 的 MethodChannel 上暴露两个方法:

方法入参返回说明
sendEmail.toJson()(Map)void拉起系统邮件 composer
getCapabilitiesMap<String, bool>canSend 等)原生侧能力探测

Email.toJson() 的 key(注意是 snake_case):subject / body / recipients / cc / bcc / attachment_paths / is_html

3.3 Email 与 EmailCapabilities 模型

适配前必须精读这两个模型,因为校验逻辑在 Dart 侧,鸿蒙实现的行为要跟它对齐:

// platform_interface/lib/src/email_capabilities.dart(节选)
void validateEmail(Email email, {required String platformName}) {
  if (!canSend) {
    throw PlatformException(code: 'not_available',
        message: 'Email composer is unavailable on $platformName.');
  }
  final unsupported = unsupportedFeaturesFor(email);
  if (unsupported.isEmpty) return;
  throw PlatformException(code: 'unsupported',
      message: 'The current platform does not support: ${unsupported.join(', ')}.');
}

send() 的完整链路是:getCapabilities()validateEmail()(canSend 为 false 抛 not_available;带了平台不支持的 cc/HTML/附件等抛 unsupported)→ 通过后才 invokeMethod('send', email.toJson())。如果鸿蒙侧的 getCapabilities 没接上,业务代码第一次调用就会在 Dart 层被拦死,根本走不到 ArkTS——这就是为什么 Dart 平台补丁(4.6 节)是必做的。

3.4 Android 原版实现剖析(鸿蒙方案的分岔点)

android/.../FlutterEmailSenderPlugin.kt 的核心逻辑:

private fun canSendMail(): Boolean {
    val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
    return activity!!.packageManager.resolveActivity(intent, 0) != null
}

// send():
// 无附件 → action = ACTION_SENDTO; data = Uri.parse("mailto:")
//          subject/body/recipients/cc/bcc 全走 putExtra(EXTRA_*)
// 有附件 → ACTION_SEND + FileProvider URI + EXTRA_STREAM + ClipData
//          + selector = Intent(ACTION_SENDTO, mailto:) 用来筛选邮件类应用

读到这里分岔点就清楚了:Android 是"mailto: 只做应用筛选器,真正的字段挂在 extras 上"。而鸿蒙 Want 的 parameters 只在同签名应用间可靠传递,跨应用(我们 → 华为邮件)最通用的契约就是 action + uri + type 三元组的隐式 Want。结论:鸿蒙侧必须走"纯 mailto"路线,把 subject/body/cc/bcc 全部编进 URI 的 query 参数——这正是 RFC 6068 定义的语法,也是 iOS 生态通行的做法,华为邮件应用对 mailto: 的支持非常完整。

3.5 鸿蒙侧 API 盘点

  • UIAbilityContext.startAbility(want):隐式拉起,Want = {action: 'ohos.want.action.viewData', uri: 'mailto:...'},返回 Promise,能 catch 到"无应用响应"错误
  • openLink(API 12+):语义更贴切但同样是 UIAbilityContext 专属,且默认带应用选择弹窗等行为差异,本文用更通用的 startAbility
  • 插件侧 FlutterPluginBinding.getApplicationContext()没有 startAbility——这是本库适配的核心矛盾,见 4.5 节
  • flutter_ohosAbilityAware:理论上可在 onAttachedToAbility 拿 ability,但 FlutterAbility 派生链路下时序不稳,社区主流做法是宿主显式转发(async_wallpaper、flutter_sharing_intent 同款决策)

四、适配实现

4.1 依赖写法(federated 双包声明)

主包 packages/flutter_email_sender/pubspec.yaml 把 ohos 路由到 method_channel 包:

flutter:
  plugin:
    platforms:
      android:
        default_package: flutter_email_sender_method_channel
      ios:
        default_package: flutter_email_sender_method_channel
      macos:
        default_package: flutter_email_sender_method_channel
      ohos:                                    # ← 新增
        default_package: flutter_email_sender_method_channel
      web:
        default_package: flutter_email_sender_web

mc 包 packages/flutter_email_sender_method_channel/pubspec.yaml 声明 ohos 原生插件类和 Dart 插件类:

flutter:
  plugin:
    implements: flutter_email_sender
    platforms:
      android:
        package: com.sidlatau.flutteremailsender
        pluginClass: FlutterEmailSenderPlugin
        dartPluginClass: MethodChannelFlutterEmailSender
      ios:
        pluginClass: FlutterEmailSenderPlugin
        dartPluginClass: MethodChannelFlutterEmailSender
      ohos:                                     # ← 新增
        pluginClass: FlutterEmailSenderMethodChannelPlugin
        dartPluginClass: MethodChannelFlutterEmailSender
      macos:
        pluginClass: FlutterEmailSenderPlugin
        dartPluginClass: MethodChannelFlutterEmailSender

pluginClass 指向 ArkTS 插件类(ohos/index.ets default export 的那个),dartPluginClass 指向联邦 Dart 实现——这样 FlutterEmailSenderPlatform.instance 在 ohos 平台会自动实例化到我们的 MethodChannel 版本。

4.2 ohos/ 骨架

flutter_email_sender_method_channel/ohos/
├── BuildProfile.ets
├── index.ets                        # export default FlutterEmailSenderMethodChannelPlugin
├── oh-package.json5                 # 无额外依赖(flutter_ohos HAR 由构建动态注入)
├── build-profile.json5
└── src/main/
    ├── module.json5                 # type: "har",name 必须与包目录名一致
    └── ets/components/plugin/
        └── FlutterEmailSenderMethodChannelPlugin.ets   # 全部实现,约 130 行

三个骨架细节:

  1. HAR 的 module.json5name 必须与包目录名完全一致flutter_email_sender_method_channel),这是 flutter_ohos 构建时按名找 HAR 的硬规则,也是宿主 import ... from 'flutter_email_sender_method_channel' 的解析依据。
  2. HAR 的 module.json5 不允许 requestPermissions——本库也不需要任何权限(拉起邮件应用是隐式 Want,无需声明)。
  3. oh-package.json5 的 dependencies 留空即可,@ohos/flutter_ohos 由 flutter_tools 构建期动态注入。

4.3 ArkTS 插件核心(完整实现)

ohos/src/main/ets/components/plugin/FlutterEmailSenderMethodChannelPlugin.ets

import { FlutterPlugin, FlutterPluginBinding } from '@ohos/flutter_ohos/src/main/ets/embedding/engine/plugins/FlutterPlugin';
import MethodChannel, { MethodCallHandler, MethodResult } from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodChannel';
import MethodCall from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodCall';
import common from '@ohos.app.ability.common';
import Want from '@ohos.app.ability.Want';

export default class FlutterEmailSenderMethodChannelPlugin implements FlutterPlugin, MethodCallHandler {
  private methodChannel?: MethodChannel;
  private uiContext?: common.UIAbilityContext;
  private static instance?: FlutterEmailSenderMethodChannelPlugin;
  private static pendingContext?: common.UIAbilityContext;

  getUniqueClassName(): string {
    return 'FlutterEmailSenderMethodChannelPlugin';   // 与 pubspec pluginClass 一致
  }

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.methodChannel = new MethodChannel(binding.getBinaryMessenger(), 'flutter_email_sender');
    this.methodChannel.setMethodCallHandler(this);
    FlutterEmailSenderMethodChannelPlugin.instance = this;
    if (FlutterEmailSenderMethodChannelPlugin.pendingContext !== undefined) {
      this.uiContext = FlutterEmailSenderMethodChannelPlugin.pendingContext;  // 宿主先到:补领 context
    }
  }

  onDetachedFromEngine(binding: FlutterPluginBinding): void {
    this.methodChannel?.setMethodCallHandler(null);
    FlutterEmailSenderMethodChannelPlugin.instance = undefined;
  }

  /// Called from the host UIAbility so the plugin can start abilities.
  static setContext(ctx: common.UIAbilityContext): void {
    FlutterEmailSenderMethodChannelPlugin.pendingContext = ctx;
    const that = FlutterEmailSenderMethodChannelPlugin.instance;
    if (that !== undefined) {
      that.uiContext = ctx;                          // 插件先到:直接注入
    }
  }

  onMethodCall(call: MethodCall, result: MethodResult): void {
    switch (call.method) {
      case 'send':
        this.send(call, result);
        break;
      case 'getCapabilities':
        result.success({ 'canSend': true });         // 探测下沉到 send 的失败路径
        break;
      default:
        result.notImplemented();
        break;
    }
  }

  private send(call: MethodCall, result: MethodResult): void {
    const args = call.args as Record<string, Object>;
    const subject = this.str(args, 'subject');
    const body = this.str(args, 'body');
    const isHtml = args['is_html'] === true;
    const recipients = this.strList(args, 'recipients');
    const cc = this.strList(args, 'cc');
    const bcc = this.strList(args, 'bcc');

    if (recipients.length === 0) {
      result.error('invalid', 'recipients must not be empty', null);
      return;
    }

    const uri = this.buildMailto(recipients, cc, bcc, subject, body, isHtml);
    const ctx = this.uiContext;
    if (ctx === undefined) {
      result.error('not_available', 'no UIAbility context forwarded', null);
      return;
    }
    const want: Want = { action: 'ohos.want.action.viewData', uri: uri };
    ctx.startAbility(want).then(() => {
      result.success(null);
    }).catch((e: Object) => {
      result.error('not_available', 'no mail app found: ' + e, null);  // 无邮件应用时的兜底
    });
  }
  // buildMailto / enc / str / strList 见 4.4 节
}

参数解析的 ArkTS 严格模式要点:call.args 断言为 Record<string, Object>(绝不能写 any);str() 对非 string 值返回空串;strList()instanceof Array 判定再 filter 收敛为 string 数组——这套类型防御保证 3.2 节协议里任何字段缺省或类型漂移都不会崩。

4.4 mailto 构造:把整封邮件压进一个 URI

这是鸿蒙侧最核心的 20 行,值得逐条讲清楚:

private buildMailto(recipients: string[], cc: string[], bcc: string[],
  subject: string, body: string, isHtml: boolean): string {
  const to = recipients.map((r: string): string => r.trim()).join(',');
  const params: string[] = [];
  if (cc.length > 0)   { params.push('cc=' + this.enc(cc.join(','))); }
  if (bcc.length > 0)  { params.push('bcc=' + this.enc(bcc.join(','))); }
  if (subject.length > 0) { params.push('subject=' + this.enc(subject)); }
  const bodyText = isHtml ? body.replace(/\n/g, '<br>') : body;
  if (bodyText.length > 0) { params.push('body=' + this.enc(bodyText)); }
  const query = params.join('&');
  return 'mailto:' + to + (query.length > 0 ? '?' + query : '');
}

private enc(v: string): string { return encodeURIComponent(v); }

对照 RFC 6068 的语法 mailto:addr[,addr]*[?key=value[&key=value]],几个刻意的设计:

  • 收件人进 path、其余进 querymailto: 后直接跟逗号分隔的收件人列表(trim 过滤空串),cc/bcc/subject/body 作为 hname=hvalue 对挂在 ? 后——这是 RFC 6068 明确定义的四个标准 header 扩展,华为邮件、Gmail、Apple Mail 全部认。
  • 全部 encodeURIComponent:中文主题(如 demo 里的"来自鸿蒙 Flutter 的问候")、& ? = # 这些会破坏 URI 结构的字符、换行,全部百分号编码后再拼。实测中文主题在华为邮件 composer 里完整还原,无乱码。
  • 空字段不进 querycc/bcc/subject/body 为空时干脆不 push,避免产出 cc=&bcc=& 这种把空值显式传给邮件客户端的 URI——有的客户端会把空 cc= 解析成一个空收件人行。
  • isHTML 的换行转换:mailto 的 body 参数是单行文本,\n 经过编码后多数客户端能还原,但 HTML 邮件里换行语义应该由 <br> 承担——所以 isHTML 为 true 时先做 \n → <br> 替换。HTML 标签是否被渲染成富文本由邮件客户端决定(与 Android 侧 EXTRA_HTML_TEXT 的行为一致,都是"尽力而为")。
  • 空收件人前置校验recipients 为空在构造 URI 前就返回 result.error('invalid', ...)——mailto:?subject=...(无收件人)虽然 RFC 允许,但业务上几乎必然是 bug,尽早失败比静默拉起一个空 composer 好。

最终产出的 URI 长这样(demo 默认表单值):

mailto:hello@example.com?subject=%E6%9D%A5%E8%87%AA%E9%B8%BF%E8%92%99...&body=%E8%BF%99%E5%B0%81%E9%82%AE%E4%BB%B6...

4.5 静态 Context 转发:UIAbilityContext 的时序问题

startAbilityUIAbilityContext 的方法,但插件的 onAttachedToEngine(binding) 只给 applicationContext——它没有拉起 Ability 的能力。所以必须让宿主 EntryAbility 把自己的 context"递"进来。

问题在于时序不确定EntryAbility.onCreate 和 Flutter 引擎初始化后插件 onAttachedToEngine 的先后没有保证(冷启动路径下两个初始化链路并行)。解法是插件侧两个静态字段做双向握手:

// example/ohos/entry/src/main/ets/entryability/EntryAbility.ets
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import Want from '@ohos.app.ability.Want';
import AbilityConstant from '@ohos.app.ability.AbilityConstant';
import FlutterEmailSenderMethodChannelPlugin from 'flutter_email_sender_method_channel';

export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    GeneratedPluginRegistrant.registerWith(flutterEngine)
  }

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    super.onCreate(want, launchParam);
    // Forward the UIAbility context so the plugin can start the mail app.
    FlutterEmailSenderMethodChannelPlugin.setContext(this.context);
  }
}

握手逻辑(两种时序都覆盖):

时序发生了什么结果
宿主先到setContextinstance 还是 undefined → context 存入 pendingContext;稍后 onAttachedToEngine 发现 pendingContext 有值 → 补领到 uiContext正常
插件先到onAttachedToEnginependingContext 为空 → 只记 instance;稍后 setContext 发现 instance 有值 → 直接注入 uiContext正常

无论哪条路,当 Dart 侧真正调 send 时(用户点按钮,必然晚于两个初始化),this.uiContext 一定是就绪的。send 里仍保留 ctx === undefined 的防御分支返回 not_available——万一是极早期的 Dart 调用,给调用方一个明确的错误码而不是崩溃。

4.6 Dart 平台补丁:两个 switch 都要加 ohos

这是本库区别于"只加 ohos 目录"型适配的关键一步。上游 method_channel_flutter_email_sender.dartgetCapabilities 是按 defaultTargetPlatform 分发的,ohos 不在名单里会直接返回 EmailCapabilities.none()canSend:false)——结合 3.3 节的 validateEmail,业务侧调用会在 Dart 层被 not_available 拦死。补丁要打两处:


Future<EmailCapabilities> getCapabilities() async {
  return switch (defaultTargetPlatform) {
    TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.macOS ||
    TargetPlatform.ohos =>                       // ← 补丁 1:ohos 走原生查询
      _nativeCapabilities(),
    _ => const EmailCapabilities.none(),
  };
}

Future<EmailCapabilities> _nativeCapabilities() async {
  final capabilities = await _channel.invokeMapMethod<String, bool>(
    _getCapabilitiesMethod,
  );

  final defaults = switch (defaultTargetPlatform) {
    TargetPlatform.android || TargetPlatform.iOS ||
      TargetPlatform.ohos => _mobileCapabilities,  // ← 补丁 2:ohos 享有移动端能力集
    TargetPlatform.macOS => _macosCapabilities,
    _ => const EmailCapabilities.none(),
  };

  return EmailCapabilities(
    canSend: capabilities?['canSend'] ?? defaults.canSend,  // 通道值优先
    supportsCc: defaults.supportsCc,
    supportsBcc: defaults.supportsBcc,
    supportsSubject: defaults.supportsSubject,
    supportsPlainTextBody: defaults.supportsPlainTextBody,
    supportsHtmlBody: defaults.supportsHtmlBody,
    supportsAttachments: defaults.supportsAttachments,
  );
}

两个 switch 的语义不同,都不能省:

  • 外层 switch 决定"要不要去问原生"——不加 ohos,通道调用根本不会发出。
  • 内层 defaults switch 决定"原生只答了 canSend 时,其余能力字段兜底成什么"——ohos 对齐 _mobileCapabilities(cc/bcc/subject/HTML/attachments 全 true),与 Android/iOS 同一档。

合并逻辑也值得注意:只有 canSend 是"通道返回值优先、defaults 兜底",其余字段全取 defaults——因为 ArkTS 侧 getCapabilities 只返回 {canSend: true} 一个键(与 Android 原版对齐),能力明细由 Dart 侧按平台档位静态声明。

4.7 关键决策点

决策理由
纯 mailto URI 而非 Want.parameters 传字段parameters 跨应用不可靠;mailto 是 RFC 6068 标准 + 华为邮件完整支持,且与 iOS 路线同构
startAbility 而非 openLinkopenLink 同样是 UIAbilityContext 专属,且行为差异(应用选择器策略)在 PC/手机形态上不一致,viewData Want 是最通用的隐式启动契约
静态 setContext 双向握手(pendingContext + instance)onCreate 与 onAttachedToEngine 时序不确定,两个静态字段覆盖两种先后
getCapabilities 固定返回 canSend:trueArkTS 侧没有等价 resolveActivity 的轻量探测 API;"设备上有没有邮件应用"的兜底下沉到 startAbility 的 catch,错误码同为 not_available,对 Dart 调用方语义一致
不实现 AbilityAwareFlutterAbility 派生链下时序不稳;宿主显式转发是社区已验证模式(async_wallpaper、flutter_sharing_intent 同款)
空字段不进 query避免空 cc= 被部分客户端解析成空收件人行
MethodCall 独立文件 importflutter_ohos HAR 里 MethodCall 是独立 default export,不是 MethodChannel 的 named export(同 flutter_sharing_intent 踩过的坑)

五、demo 验证与运行效果

5.1 demo 设计

example/lib/main.dart 设计成一个"全字段可编辑 + 事件流可观测"的验证台:顶部深绿色能力卡实时显示 getCapabilities 返回的 7 个能力位;中间是 to(逗号分隔)/cc/subject/body 四个输入框 + isHTML 开关;底部 send() 按钮和暗色事件流日志卡(每次通道调用打一条时间戳日志,保留最近 20 条)。默认表单值就是一组含中文主题、中文正文的完整邮件,方便直接点 send 验证编码链路。

5.2 模拟器(DevEco Emulator,无邮件应用)

capabilities
getCapabilities 真实返回 canSend:true cc:true bcc:true subject:true html:true attachments:true(Dart 平台补丁生效)

首屏能力卡显示 canSend:true cc:true bcc:true subject:true html:true attachments:true——注意这是真实的通道返回链路:Dart 补丁让 ohos 走 _nativeCapabilities() → MethodChannel 到 ArkTS → result.success({'canSend': true}) → Dart 合并 _mobileCapabilities 明细。4.6 节两个 switch 缺任何一个,这里都会是全 false。

send 触发
点 send 后绿色系统进度条出现(鸿蒙系统在解析 mailto 的隐式启动能力)

send() 后系统绿色进度条出现——这是鸿蒙在为 ohos.want.action.viewData + mailto: 的隐式 Want 做能力匹配。模拟器没有预装邮件应用,系统进入"查找可响应应用"的等待状态,随后 startAbility Promise reject,Dart 侧 catch 到 PlatformException(not_available, no mail app found: ...) 打进事件流。这条失败路径本身就是有效验证:mailto URI 被系统正确解析、编码无误、错误码语义与 Android 原版的 No email clients found! 对齐。

5.3 真机(HarmonyOS 7.0.0 MateBook Pro)

真机系统邮件应用(华为邮件)响应 mailto: 拉起 compose,预填主题正文收件人

真机上点 send(),华为邮件应用(com.huawei.hmos.email)被 viewData Want 拉起并进入 compose 界面,收件人、中文主题、中文正文全部预填就位——整条链路(Dart Email 模型 → toJson → MethodChannel → ArkTS buildMailto → startAbility → 系统邮件)端到端打通

5.4 不进应用的独立验证(aa start)

适配期还可以绕过 Flutter 直接验证"这台设备认不认 mailto",在 hdc shell 里:

aa start -A ohos.want.action.viewData -U 'mailto:hello@example.com?subject=test'

真机上这条命令会直接拉起华为邮件的 compose——与插件内部行为完全同构,可以用来区分"设备问题"还是"插件问题"(aa start 用法:-A action -U uri -t mime-type,隐式启动不指定 -a ability 名)。

六、FAQ:适配过程遇到的问题与解决

Q1:编译报 module name 必须一致?

mc 包 ohos/src/main/module.json5name 字段必须与包目录名完全一致(flutter_email_sender_method_channel),不要去除 _method_channel 后缀——flutter_tools 构建时按"包目录名"找 HAR,宿主 EntryAbility 的 import ... from 'flutter_email_sender_method_channel' 也按这个名字解析。

Q2:send 点击后没反应?

分两种情况:

  • 模拟器:没有默认邮件应用时 startAbility 进入"选择应用"等待(绿色进度条),随后 Promise reject 报 not_available——这是正确行为,不是 bug。
  • 真机:确认 EntryAbility.onCreate 里调了 FlutterEmailSenderMethodChannelPlugin.setContext(this.context);漏了这行则 ArkTS 侧 uiContext 为 undefined,send 直接返回 no UIAbility context forwarded

Q3:canSend:false / 能力卡全 false?

Dart 平台补丁未生效。确认 method_channel_flutter_email_sender.dart两个 switch 都加了 TargetPlatform.ohos:外层(决定走 _nativeCapabilities())和 _nativeCapabilities 内层(决定 defaults 用 _mobileCapabilities)。只加外层的话通道能通,但 cc/bcc 等明细字段全是 false,带 cc 的邮件会被 validateEmailunsupported 拦截。

Q4:openLink 不存在?

openLinkUIAbilityContext 上,插件的 FlutterPluginBinding.getApplicationContext() 没有。用 UIAbilityContext.startAbility + 静态方法 setContext 转发(4.5 节),不要试图从 applicationContext 调启动类 API。

Q5:EntryAbility 一定要改吗?时序会踩坑吗?

一定要改(startAbility 的 context 来源只有这条路)。时序问题已被双向握手覆盖:setContextonAttachedToEngine 谁先到都能对上,无需在 demo 里加任何 delay 或重试。

Q6:中文主题/正文乱码?

检查 buildMailto 里所有进 query 的字段是否都过了 this.enc()encodeURIComponent)。收件人 path 段(邮箱地址本身是 ASCII)可以不编码,但 cc/bcc 里如果混入中文显示名也必须编码。

Q7:附件(attachmentPaths)怎么办?

当前版本未透传。原因:RFC 6068 的 mailto 语法没有附件语义——Android 原版是靠 ACTION_SEND + EXTRA_STREAM + FileProvider 绕开 mailto 限制的,而鸿蒙 Want 没有等价的跨应用附件通道(parameters 跨应用不可靠、文件 URI 权限授予机制不同)。带附件调用时字段会被忽略,主题/正文/收件人正常预填。后续版本计划走 sendData Want + 沙箱文件 URI 授权的路线补齐,这也是本库与 Android 版当前的能力差异点,选型时注意。

Q8:如何测试?

开发期(无需邮件应用配合):

  • 模拟器验证 capabilities 链路 + send 的 not_available 失败路径(5.2 节)
  • hdc shell 里 aa start -A ohos.want.action.viewData -U 'mailto:...' 单独验证设备侧(5.4 节)

验收期:真机(预装华为邮件)上跑 demo 点 send,检查 compose 界面的收件人/主题/正文/cc 预填与表单一致,isHTML 开关下换行是否变 <br> 语义。

七、其他内容

7.1 总结

flutter_email_sender v10.0.x 鸿蒙适配完成:federated 双包声明 + Dart 双 switch 平台补丁 + ArkTS mailto 构造 + viewData Want 隐式启动 + 静态 Context 双向握手。选型上放弃照搬 Android 的 extras 体系,改为 RFC 6068 纯 URI 路线,与 iOS 同构、被华为邮件完整支持;getCapabilities 的探测下沉到 send 失败路径,错误码与 Android 原版语义对齐;中文主题/正文经 encodeURIComponent 无损预填,模拟器(capabilities 真实返回 + not_available 失败路径)与真机(华为邮件 compose 预填)双重验证通过。已知能力差异:附件未透传(mailto 无附件语义),规划用 sendData Want 路线补齐。

7.2 鸿蒙适配三件套清单(活动硬性要求)

  • ohos/ 骨架(oh-package.json5 / build-profile.json5 / module.json5 / index.ets / src/main/ets/components/plugin/*.ets
  • example/ohos(独立可运行调试工程,EntryAbility 覆写 onCreate 转发 UIAbilityContext,build-profile 带 signingConfig: default)
  • README.OpenHarmony 说明(适配范围 + 依赖写法 + EntryAbility 接入步骤)

7.3 参考链接

欢迎加入 CPF-Flutter 鸿蒙社区,社区入口、环境搭建指南和三方库链接统一放在这里:

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐