Flutter 三方库 flutter_email_sender 的鸿蒙适配教程

本文配套仓库:https://atomgit.com/oh-flutter/flutter_email_sender(TAG:10.0.1-ohos-1.0.0-beta.1,分支:feat/ohos_flutter_email_sender_10.0.1)。插件的接口用法、参数说明与业务侧最佳实践,见配套的《给鸿蒙 App 增加唤起系统邮件发送能力 —— flutter_email_sender 的鸿蒙使用指南》;本文解决的是另一件事:从上游 GitHub 仓库开始,把 flutter_email_sender 完整适配到 OpenHarmony / HarmonyOS 平台,并在模拟器与真机上验证。

flutter_email_sender 是 pub.dev 上的一个拉起原生邮件编辑器发送邮件的 Flutter 插件(作者 sidlatau,Apache-2.0 协议,10.0.1),月下载量超过十三万。构造一个 Email 对象调用 FlutterEmailSender.send(),插件负责把收件人、主题、正文和附件带进系统邮件界面,是应用内"意见反馈"“邀请注册”"错误上报"这类功能的标准选择。它原本支持 Android、iOS、macOS 与 Web,唯独没有鸿蒙;而鸿蒙 Flutter 应用要发邮件,得自己对接 ArkTS 侧的 startAbilityByType('mail') 系统邮件应用选择面板。本文完整走一遍社区三方库适配的标准流程:把上游仓库同步到 AtomGit,拉到宿主机,建适配分支,用命令自动补全 ohos 目录,补全 Dart 与 ArkTS 两侧实现,补齐适配说明文件后提交分支与 TAG,最后用仓库自带的 example 在 DevEco 模拟器上验证。

一、环境搭建

鸿蒙 Flutter 开发环境(ohos 版 SDK、DevEco Studio、签名配置)的完整搭建步骤,官方指南已经写得很细,直接照做即可:

Flutter OHOS 开发环境搭建指南

适配工作比单纯使用多一项要求:终端里 flutter 命令必须指向 ohos 版 SDK,因为后文自动补全 ohos 目录靠的是它提供的 flutter create --platforms ohos 能力。环境装好后用 flutter devices 确认能识别鸿蒙设备。本文实测使用的环境:

版本
Flutter(ohos 版)3.41.10-ohos-1.0.1
编译 SDK26.0.0(26)
实测设备DevEco 模拟器(OpenHarmony 7.0.0.105 / API 26);HUAWEI ADA-AL10U 真机(HarmonyOS 6.1.1.120 / API 24)

二、适配过程

2.1 将上游仓库同步到 AtomGit

鸿蒙 Flutter 三方库社区(oh-flutter 组织)托管在 AtomGit,上游项目在 GitHub,适配的第一步是把上游代码完整迁入 AtomGit 上为它新建的目标仓库 oh-flutter/flutter_email_sender。做法是把上游克隆下来,添加 AtomGit 远端后整库推送,保留全部 commit 历史与 TAG,后续上游发新版时也能用同样的方式增量同步:

# 克隆上游仓库,目录名与目标仓库保持一致
git clone https://github.com/sidlatau/flutter_email_sender.git flutter_email_sender
cd flutter_email_sender

# 关联 AtomGit 目标仓库
git remote add atomgit https://atomgit.com/oh-flutter/flutter_email_sender.git

# 推送全部分支与 TAG
git push atomgit --all
git push atomgit --tags

推送完成后,打开 AtomGit 上目标仓库的页面,能看到与上游一致的提交历史和源码目录:

在这里插入图片描述

图一:同步完成后 AtomGit 目标仓库的代码页

2.2 拉取代码到宿主机

从目标仓库把代码拉到本地,后续所有操作都在这份代码上进行:

git clone https://atomgit.com/oh-flutter/flutter_email_sender.git
cd flutter_email_sender

此时的目录是上游的原始结构。和 maps_launcher 这类单包插件不同,flutter_email_sender 上游采用 federated plugin(联邦插件)组织,一个功能拆成多个包:

flutter_email_sender/
├── packages/
│   ├── flutter_email_sender/                    # app-facing 包:Dart API(Email、send、getCapabilities)与 example
│   ├── flutter_email_sender_method_channel/     # 平台通道实现包:Android / iOS / macOS 的原生对接
│   ├── flutter_email_sender_platform_interface/ # 平台接口包:FlutterEmailSenderPlatform、Email 模型、能力校验
│   └── flutter_email_sender_web/                # Web 实现
├── pubspec.yaml
├── README.md
└── ...

联邦插件的好处是适配落点非常明确:业务接口在 app-facing 包,平台校验在 platform_interface 包,两者都不用动;鸿蒙实现只需要落在新平台通道实现包上,也就是 packages/flutter_email_sender_method_channel。这个判断决定了后文所有改动的位置。

在这里插入图片描述

图二:clone 完成后的仓库目录

2.3 创建适配分支并补全 ohos 目录结构

社区约定适配分支统一以 feat/ohos_库名称_版本号 命名,本次适配的上游版本是 10.0.1,分支名就是 feat/ohos_flutter_email_sender_10.0.1。先建分支:

git checkout -b feat/ohos_flutter_email_sender_10.0.1

然后在平台通道实现包的目录里执行一条命令,自动完成 ohos 适配结构的补全:

cd packages/flutter_email_sender_method_channel
flutter create --platforms ohos .

注意执行位置:联邦插件的 flutter create --platforms ohos . 要在 method_channel 包根目录运行,而不是仓库根目录。这条命令由 ohos 版 Flutter SDK 提供,它读取该包 pubspec.yaml 里的插件名,自动生成完整的 ohos/ 目录,并在 plugin.platforms 下追加 ohos 注册节点:

      # ... android、ios、macos 等原有节点保持不变
      ohos:
        package: com.sidlatau.flutteremailsender
        pluginClass: FlutterEmailSenderPlugin
        dartPluginClass: MethodChannelFlutterEmailSender
        dartFileName: src/method_channel_flutter_email_sender.dart

生成的 ohos/ 目录结构:

ohos/
├── src/main/ets/components/plugin/   # 插件 ArkTS 实现落点
├── index.ets                         # 导出入口(export FlutterEmailSenderPlugin)
├── oh-package.json5                  # 鸿蒙包描述
└── build-profile.json5

pluginClass 指向接下来要写的 ArkTS 插件类,dartPluginClassdartFileName 沿用上游的 Dart 侧实现,宿主工程构建时会按这些注册信息自动生成插件注册代码,不需要手写注册逻辑。

在这里插入图片描述

图三:flutter create 补全 ohos 目录后的工程结构

2.4 在插件文件中补全 ohos 实现

实现分 Dart 与 ArkTS 两侧,通道名为上游约定俗成的 flutter_email_sender,两个方法:send 发送邮件,getCapabilities 查询平台能力。

Dart 侧改动很小。上游的平台通道实现包已经按平台分发能力集,鸿蒙只需要补一个能力集常量,并让平台分发的 switch 认识 TargetPlatform.ohos(这是 Flutter 鸿蒙 SDK 内置的平台枚举)。在 lib/src/method_channel_flutter_email_sender.dart 中加入:

// 鸿蒙邮件扩展面板为纯文本语义,暂不支持 HTML 正文。
static const EmailCapabilities _ohosCapabilities = EmailCapabilities(
  canSend: true,
  supportsCc: true,
  supportsBcc: true,
  supportsSubject: true,
  supportsPlainTextBody: true,
  supportsHtmlBody: false,
  supportsAttachments: true,
);

能力集里 supportsHtmlBody: false 是本次适配的一个关键决策:鸿蒙 startAbilityByType('mail') 拉起的邮件扩展面板是纯文本语义,HTML 正文传进去不会按预期渲染,与其让用户收到一封排版错乱的邮件,不如在能力集里如实声明,把拦截交给能力校验。send 与平台分发的逻辑如下:


Future<void> send(Email email) async {
  // 能力校验在拉起之前完成:鸿蒙上 isHTML: true 会在这一步前置抛出异常
  final capabilities = await getCapabilities();
  capabilities.validateEmail(email, platformName: defaultTargetPlatform.name);
  await _channel.invokeMethod<void>('send', email.toJson());
}

ArkTS 侧是本次适配的主体,新建 ohos/src/main/ets/components/plugin/FlutterEmailSenderPlugin.ets,实现 FlutterPluginMethodCallHandlerAbilityAware 三个接口。第一段是生命周期与通道注册:

export default class FlutterEmailSenderPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
  private static readonly CHANNEL_NAME: string = 'flutter_email_sender';
  private channel: MethodChannel | null = null;
  private ability: UIAbility | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(
      binding.getBinaryMessenger(),
      FlutterEmailSenderPlugin.CHANNEL_NAME,
      StandardMethodCodec.INSTANCE
    );
    this.channel.setMethodCallHandler(this);
  }

  // AbilityAware:持有 UIAbility,后续拉起面板要用它的 context
  onAttachedToAbility(binding: AbilityPluginBinding): void {
    this.ability = binding.getAbility();
  }

  onMethodCall(call: MethodCall, result: MethodResult): void {
    switch (call.method) {
      case 'getCapabilities': {
        // 鸿蒙侧无邮件应用预查询 API(等价于 Android resolveActivity / iOS canSendMail),
        // 邮件面板由系统按已安装应用自动兜底;拉起失败经 onError 回 not_available。
        const capabilities: Record<string, Object> = { 'canSend': true };
        result.success(capabilities);
        break;
      }
      case 'send': {
        this.handleSend(call, result);
        break;
      }
      default: {
        result.notImplemented();
        break;
      }
    }
  }
}

第二段是 handleSend 里 wantParam 的组装。sceneType 固定为 1(标准邮件发送场景),收件人、抄送、密送、主题、正文逐项判空后经 encodeURI 编码传入;附件则要把本地路径转成 fileUri:

const wantParam: Record<string, Object> = { 'sceneType': 1 };

if (recipients != undefined && recipients.length > 0) {
  const email: string[] = [];
  for (const address of recipients) {
    email.push(encodeURI(address));
  }
  wantParam['email'] = email;
}
// cc、bcc、subject、body 同理,此处省略

// 附件:将本地路径转换为 fileUri,并授予目标应用读权限
if (attachmentPaths != undefined && attachmentPaths.length > 0) {
  const uris: string[] = [];
  for (const path of attachmentPaths) {
    uris.push(fileUri.getUriFromPath(path));
  }
  wantParam['ability.params.stream'] = uris;
  wantParam['ability.want.params.uriPermissionFlag'] =
    wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION;
}

附件这段是适配里最容易踩坑的地方:邮件应用运行在自己的沙箱里,直接传宿主应用的沙箱路径对方读不到,必须经 fileUri.getUriFromPath 转成系统可识别的 fileUri,再通过 FLAG_AUTH_READ_URI_PERMISSION 授权标志把读权限授予目标应用,整个过程不需要在 module.json5 里声明任何权限。

第三段是发起拉起并回传结果。这里有一个必须处理的细节:startAbilityByType 的异步回调(onError / onResult)与末尾的同步 AsyncCallback 可能先后触发,而 MethodResult 只允许回复一次,用 replyOnce 标志位做双向互斥:

let replied: boolean = false;
const replySuccess = (): void => {
  if (!replied) { replied = true; result.success(null); }
};
const replyNotAvailable = (message: string): void => {
  if (!replied) {
    replied = true;
    // 对齐上游 Android:result.error("not_available", "No email clients found!", null)
    result.error('not_available', message, null);
  }
};
const abilityStartCallback: common.AbilityStartCallback = {
  onError: (code: number, name: string, message: string): void => {
    replyNotAvailable(`No email clients found! (${name}: ${message}, code: ${code})`);
  },
  onResult: (abilityResult: common.AbilityResult): void => { replySuccess(); }
};
context.startAbilityByType('mail', wantParam, abilityStartCallback, (err: BusinessError): void => {
  if (err && err.code != 0) {
    replyNotAvailable(`No email clients found! (code: ${err.code}, message: ${err.message})`);
  } else {
    replySuccess();
  }
});

失败路径回 not_available 是刻意为之:上游 Android 在无可用邮件应用时报 result.error("not_available", "No email clients found!", null),Dart 侧据此映射为 NotAvailableException。鸿蒙侧沿用同一个错误码,业务代码的异常处理在各平台保持一致。

2.5 补全适配说明文件并提交分支

插件实现完成后,在仓库根目录补齐四份适配说明文件:

README.OpenSource            # 开源说明(Name / License / Version Number / Owner / Upstream URL)
README.OpenHarmony_CN.md     # 中文使用说明:简介、下载安装、约束与限制、使用示例、接口说明、新增特性、遗留问题等
README.OpenHarmony.md        # 英文使用说明
CHANGELOG.OpenHarmony.md     # 鸿蒙适配变更记录与验证环境

README 里的接口说明、遗留问题两节值得认真写:鸿蒙面板不支持 HTML 正文、无邮件应用预查询 API 这两条是使用方一定会遇到的行为差异,提前写清楚能省掉大量 Issue 往返。最后提交代码并打 TAG:

# 插件侧适配改动(ohos 目录、pubspec 注册节点与四份说明文件)
git add packages/flutter_email_sender_method_channel \
  README.OpenSource README.OpenHarmony_CN.md README.OpenHarmony.md CHANGELOG.OpenHarmony.md
git commit -m "feat: adapt flutter_email_sender for the OpenHarmony platform"

# example 的适配改动单独提交
git add packages/flutter_email_sender/example
git commit -m "feat: rework example for OpenHarmony verification"

# TAG 命名规则:原库版本-ohos-版本号-beta.x
git tag 10.0.1-ohos-1.0.0-beta.1

# 推送分支与 TAG 到 AtomGit
git push atomgit feat/ohos_flutter_email_sender_10.0.1
git push atomgit 10.0.1-ohos-1.0.0-beta.1

TAG 是后续使用方以 git 依赖引入时的版本锚点,TAG 名、README 与 CHANGELOG 三处保持一致,推完 TAG 适配的代码部分就完成了。

在这里插入图片描述
在这里插入图片描述

图四:适配分支与 TAG 推送到 AtomGit

三、在 Demo 中验证适配效果

3.1 使用仓库自带的 example

上游 example 位于 packages/flutter_email_sender/example,覆盖了插件全部能力:收件人、主题、正文三个输入框,右上角发送按钮,底部附件区(添加与删除附件),并根据 getCapabilities() 的结果动态调整界面——canSend 为 false 时禁用发送按钮,supportsHtmlBody 为 false 时隐藏 HTML 开关。为 example 生成 ohos 宿主后即可构建:

cd packages/flutter_email_sender/example
flutter pub get
flutter build hap --debug

构建产物在 ohos/entry/build/default/outputs/default/entry-default-signed.hap。安装需要签名:用 DevEco Studio 打开 example 的 ohos 工程,在 File > Project Structure > Signing Configs 里配置调试签名。注意调试签名 profile 与 bundleName 绑定,若借用其他工程的签名材料,需将 AppScope/app.json5 的 bundleName 改成与 profile 一致。装到设备并启动:

hdc install entry-default-signed.hap
aa start -a EntryAbility -b com.example.demo

3.2 自建工程时以 AtomGit 链接方式引入

不用 example、在自己的工程里验证的话,按 git 依赖方式引入即可,url、ref 两项直接用本文开头的仓库地址与 TAG。完整的引入步骤、参数说明与业务实战,见姊妹篇《flutter_email_sender 的鸿蒙使用指南》第四章,此处不展开。

3.3 调用接口并观察真机运行效果

启动 example,主界面如下,收件人、主题、正文已预填默认值:

在这里插入图片描述

图五:example 启动后的主界面

点击右上角发送按钮,插件经 startAbilityByType('mail') 拉起系统邮件面板。真机上安装并登录了华为邮件,面板列出「电子邮件」可选;早期在未装邮件应用的模拟器上验证时,面板给出「暂无可用邮件发送方式」的兜底提示——同样说明拉起链路已打通,该场景截图见 4.2 Q1:

在这里插入图片描述

图六:点击发送后系统弹出邮件应用选择面板

选择「电子邮件」,进入写邮件界面,收件人、主题、正文自动填充,无附件链路完整闭环:

在这里插入图片描述

图七:选定邮件应用后写邮件界面自动填充

验证附件流程前,先交代一下 example 在鸿蒙上的一个处理:上游 example 选附件走 file_selector 与 image_picker,这两个库暂无鸿蒙实现,example 按 TargetPlatform.ohos 分支改用内置 asset——把打包进应用的样例附件复制到应用临时目录再挂到附件列表,附件流程因此可以在设备上完整跑通。点击右下角回形针按钮,附件行出现,路径形如 /data/storage/el2/base/haps/entry/cache/sample_attachment.txt

在这里插入图片描述

图八:点击附件按钮后新增附件行

带附件点击发送,附件路径经 ArkTS 侧 fileUri.getUriFromPath 转换后随 want 传入系统层,选择「电子邮件」进入写邮件界面,顶部显示「1 个附件 (373 B)」——附件真实到达了邮件应用:

在这里插入图片描述

图九:带附件发送,写邮件界面列出附件

附件授权在 hilog 里有一条完整的证据链:面板进程收到 want 时 paramCheck 打出附件 uri(单斜杠的 file://com.example.demo/data/storage/el2/base/haps/entry/cache/sample_attachment.txt);沙箱策略管理为其添加路径策略(Total 1 path policy added)并完成面板自身的授权(GrantUriPermissionWithType finished);用户选定邮件应用后,系统把 uri 授权给目标邮件应用(grant targetBundle:com.huawei.hmos.emailCheckPermission ... passSetPolicy to targetTokenId ...),最后 Start target uiAbility with result: 0 拉起写邮件界面,全程无插件异常。

这条链路里藏着一个深坑:附件路径里若混入连续分隔符(cache//sample_attachment.txt),沙箱策略校验会直接失败、授权被拒,邮件应用静默丢弃附件,而发送流程看起来一切正常——适配后期正是在真机上抓到并修掉了这个问题,根因与修法见 4.2 Q3。

到这里适配完成度就有了实测背书:sendgetCapabilities 两个接口全部调通,无附件与带附件两条链路都到达邮件应用并正确回调,能力校验按预期放行纯文本邮件。

四、常见问题

4.1 适配过程中的问题

Q1:联邦插件结构,flutter create --platforms ohos . 该在哪个目录执行?

在平台通道实现包的根目录(packages/flutter_email_sender_method_channel)执行,生成的 ohos/ 目录与 pubspec 的 ohos 注册节点都落在这个包里。仓库根目录与 app-facing 包不需要执行,执行了反而会生成一个多余的空壳 ohos 工程。

Q2:fileUri.getUriFromPath 编译报参数不匹配?

API 26 上该接口签名为单参 getUriFromPath(path: string)。适配过程中曾按旧文档写法传入两个参数(path 与 context),编译器直接报参数数量错误,改为单参后编译通过,运行时转换也正常。遇到类似报错先核对实测 SDK 版本的接口签名,不要照搬旧示例。

Q3:构建出的 hap 装不上,报签名相关错误?

hap 必须签名才能安装。用 DevEco Studio 打开 example 的 ohos 工程配置调试签名即可;特别注意调试签名 profile 绑定 bundleName,借用的签名材料要求 AppScope/app.json5 的 bundleName 与 profile 一致(本文 example 为 com.example.demo)。

Q4:ArkTS 侧 result.success 被调用两次导致崩溃?

startAbilityByTypeAbilityStartCallbackonError / onResult)与末尾的同步 AsyncCallback 可能先后触发,而 MethodResult 只允许回复一次。参照 2.4 的实现,用 replied 标志位包裹所有回复路径,第一次结果生效后其余丢弃。

Q5:compatibleSdkVersion 与设备 API 不匹配?

编译 SDK 版本需与实测设备匹配:本文实测 DevEco 模拟器为 API 26,宿主工程 compatibleSdkVersion"26.0.0";若目标设备 API 低于 26(如 HarmonyOS 6.1.x 真机为 API 24),需将 compatibleSdkVersion 调整为不高于设备 API 的版本(6.1.0(23),注意保留带括号的旧格式),否则安装时报"此应用暂不支持在当前设备安装"。

4.2 使用过程中的问题

Q1:点击发送提示「暂无可用邮件发送方式」是怎么回事?

设备上没有已安装并声明邮件能力的应用。这不是插件故障——拉起请求已到达系统层并正确回调(SnackBar success)。安装任意一个邮件应用并登录后再调用,面板就会列出该应用并进入写邮件界面。

在这里插入图片描述

Q2:isHTML: true 为什么直接抛异常?

鸿蒙邮件扩展面板是纯文本语义,插件在鸿蒙能力集里声明 supportsHtmlBody: falsesend() 的能力校验在拉起之前就把不支持的请求拦截,抛 FlutterEmailSenderUnsupportedFeatureException。鸿蒙上使用纯文本正文即可。

Q3:面板与写邮件界面都正常,但邮件应用里看不到附件?

检查附件路径里有没有连续的分隔符(形如 .../cache//sample_attachment.txt)。鸿蒙上 Directory.systemTemp.path 这类目录路径以 / 结尾,拼接文件名时若再手动补一个分隔符,路径里就会出现 //。系统应用选择面板替目标邮件应用申请文件读权限时会做沙箱策略校验,双斜杠路径校验失败、授权被拒(hilog 可见 CheckSandboxPolicy failedGrantUriPermission ret: 2097177),邮件应用拿不到附件便静默丢弃——而发送流程从面板到写邮件界面看起来完全正常。

修复方式:拼接路径前先去掉目录尾部的分隔符,保证传入插件的路径不含连续分隔符;插件鸿蒙侧也在 getUriFromPath 之前对路径做了防御性折叠,双保险。example 的处理可参考其 _copySampleAttachment

Q4:send() 正常返回就代表邮件发出去了吗?

正常返回代表"系统面板拉起成功、用户已进入选择与发送流程",不保证邮件已发出——最终发送由用户在选定的邮件应用里完成。设备无可用邮件应用时抛 FlutterEmailSenderNotAvailableException

五、结语

回顾整条适配链路:上游仓库同步进 AtomGit 保住历史;联邦插件结构让适配落点收敛在 method_channel 包,flutter create --platforms ohos . 一条命令补全目录;改动只落在 Dart 侧的一个能力集常量与 ArkTS 侧的一个插件文件里,startAbilityByType('mail') 加上 replyOnce 互斥与 fileUri 附件授权,就把上游 send / getCapabilities 的语义完整搬到了鸿蒙;四份说明文件交代清楚适配行为与遗留差异,分支与 TAG 按社区规范提交发布。原库的接口签名、Email 模型与异常语义原封未动,这正是"只做加法"的适配给使用方的承诺。

使用中发现问题,欢迎到 flutter_email_sender 鸿蒙仓库提 Issue(鸿蒙适配层,标题建议 [Bug] 一句话现象,附上复现步骤、期望与实际结果、设备与系统版本、Flutter 鸿蒙 SDK 版本及日志截图),插件上游行为的问题到原库 GitHub 仓库反馈,修复代码欢迎发 PR。接口的完整用法、参数表与业务侧最佳实践,见姊妹篇《flutter_email_sender 的鸿蒙使用指南》。

六、相关链接

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐