Flutter for OpenHarmony 实现 HarmonyOS NEXT 服务卡片开发教程

概述

服务卡片(Form Widget)是 HarmonyOS 的特色功能,允许用户在桌面上直接查看应用信息并进行快捷操作。由于 Flutter 本身不直接支持服务卡片,我们需要通过 Platform Channel 与原生 ArkTS 代码进行通信,在原生层实现服务卡片功能。

技术架构

Flutter 应用层 (Dart)
    ↓ MethodChannel 通信
原生插件层 (ArkTS) - FormWidgetPlugin
    ↓ 调用服务
数据服务层 (ArkTS) - FormDataService
    ↓ 更新卡片
卡片扩展层 (ArkTS) - FormExtensionAbility
    ↓ 渲染
卡片 UI 层 (ArkTS) - WidgetCard.ets

核心思路:Flutter 通过 MethodChannel 将数据传递给原生层,原生层负责管理和更新服务卡片。

项目结构

your_flutter_project/
├── lib/
│   └── services/
│       └── form_widget_service.dart    # Flutter 侧通信服务
└── ohos/entry/src/main/
    ├── ets/
    │   ├── entryability/
    │   │   ├── EntryAbility.ets        # 应用入口,注册插件
    │   │   └── FormWidgetPlugin.ets    # 原生通信插件
    │   ├── formability/
    │   │   └── MyFormAbility.ets       # 卡片生命周期管理
    │   ├── widget/pages/
    │   │   └── WidgetCard.ets          # 卡片 UI 界面
    │   └── services/
    │       └── FormDataService.ets     # 卡片数据服务
    └── resources/base/profile/
        └── form_config.json            # 卡片配置文件

第一步:配置服务卡片

1.1 创建卡片配置文件

ohos/entry/src/main/resources/base/profile/ 目录下创建 form_config.json

{
  "forms": [{
    "name": "MyWidget",
    "description": "应用服务卡片",
    "src": "./ets/widget/pages/WidgetCard.ets",
    "uiSyntax": "arkts",
    "isDefault": true,
    "updateEnabled": true,
    "updateDuration": 1,
    "defaultDimension": "2*2",
    "supportDimensions": ["2*2", "2*4", "4*4"]
  }]
}

配置说明:

  • name - 卡片唯一标识名称
  • src - 卡片 UI 页面的文件路径
  • updateEnabled - 开启定时更新功能
  • updateDuration - 更新周期,单位为 30 分钟(值为 1 表示每 30 分钟更新一次)
  • supportDimensions - 支持的卡片尺寸,22 为小卡片,44 为大卡片

1.2 注册卡片扩展能力

ohos/entry/src/main/module.json5extensionAbilities 数组中添加:

{
  "name": "MyFormAbility",
  "srcEntry": "./ets/formability/MyFormAbility.ets",
  "type": "form",
  "exported": true,
  "metadata": [{
    "name": "ohos.extension.form",
    "resource": "$profile:form_config"
  }]
}

配置说明:

  • type: "form" - 声明这是一个服务卡片扩展
  • metadata - 关联上一步创建的卡片配置文件
  • exported: true - 允许系统访问此扩展能力

第二步:实现卡片生命周期管理

ohos/entry/src/main/ets/formability/ 目录下创建 MyFormAbility.ets

import { FormExtensionAbility, formInfo, formBindingData } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { FormDataService } from '../services/FormDataService';

export default class MyFormAbility extends FormExtensionAbility {
  
  // 用户添加卡片到桌面时触发
  onAddForm(want: Want): formBindingData.FormBindingData {
    const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string;
    FormDataService.getInstance().registerForm(formId);
    return formBindingData.createFormBindingData({ title: '加载中...', count: 0 });
  }
  
  // 系统定时更新卡片时触发
  onUpdateForm(formId: string): void {
    FormDataService.getInstance().updateForm(formId);
  }
  
  // 用户删除卡片时触发
  onRemoveForm(formId: string): void {
    FormDataService.getInstance().unregisterForm(formId);
  }
  
  // 用户点击卡片上的交互元素时触发
  onFormEvent(formId: string, message: string): void {
    if (message === 'refresh') {
      FormDataService.getInstance().updateForm(formId);
    }
  }
}

代码说明:

  • onAddForm - 卡片创建回调,需要返回初始数据,同时将卡片 ID 注册到数据服务中以便后续更新
  • onUpdateForm - 定时更新回调,根据配置的 updateDuration 周期性触发
  • onRemoveForm - 卡片删除回调,清理已注册的卡片 ID,释放资源
  • onFormEvent - 事件回调,处理卡片上按钮点击等交互事件

第三步:实现卡片数据服务

ohos/entry/src/main/ets/services/ 目录下创建 FormDataService.ets

import { formProvider, formBindingData } from '@kit.FormKit';
import { preferences } from '@kit.ArkData';

export class FormDataService {
  private static instance: FormDataService;
  private formIds: Set<string> = new Set();           // 存储所有已注册的卡片 ID
  private dataPreferences: preferences.Preferences | null = null;  // 本地数据存储
  
  static getInstance(): FormDataService {
    if (!FormDataService.instance) {
      FormDataService.instance = new FormDataService();
    }
    return FormDataService.instance;
  }
  
  // 初始化本地存储(在 EntryAbility 中调用)
  async initPreferences(context: Context): Promise<void> {
    this.dataPreferences = await preferences.getPreferences(context, 'formDataStore');
  }
  
  // 注册卡片 ID
  registerForm(formId: string): void {
    this.formIds.add(formId);
    this.updateForm(formId);  // 注册后立即更新数据
  }
  
  // 注销卡片 ID
  unregisterForm(formId: string): void {
    this.formIds.delete(formId);
  }
  
  // 更新指定卡片
  async updateForm(formId: string): Promise<void> {
    const formData = await this.getFormData();
    const bindingData = formBindingData.createFormBindingData(formData);
    await formProvider.updateForm(formId, bindingData);
  }
  
  // 更新所有已注册的卡片(供 Flutter 调用)
  async updateAllForms(): Promise<void> {
    for (const formId of this.formIds) {
      await this.updateForm(formId);
    }
  }
  
  // 保存数据并刷新卡片(供 Flutter 调用)
  async saveData(key: string, value: string): Promise<void> {
    await this.dataPreferences?.put(key, value);
    await this.dataPreferences?.flush();
    await this.updateAllForms();
  }
  
  // 获取卡片显示数据
  private async getFormData(): Promise<Record<string, Object>> {
    const title = await this.dataPreferences?.get('widget_title', '我的应用') as string;
    const count = parseInt(await this.dataPreferences?.get('widget_count', '0') as string, 10);
    return { title, count, hasData: count > 0 };
  }
}

代码说明:

  • formIds - 使用 Set 存储所有活跃卡片的 ID,支持多个卡片实例
  • dataPreferences - 使用 HarmonyOS 首选项 API 持久化存储数据
  • updateForm - 核心方法,通过 formProvider.updateForm 将数据推送到指定卡片
  • saveData - 供 Flutter 调用,保存数据后自动刷新所有卡片
  • 采用单例模式确保全局只有一个数据服务实例

第四步:创建卡片 UI 界面

ohos/entry/src/main/ets/widget/pages/ 目录下创建 WidgetCard.ets

@Entry
@Component
struct WidgetCard {
  // 使用 @LocalStorageProp 绑定卡片数据,变量名必须与 FormDataService 返回的数据键名一致
  @LocalStorageProp('title') title: string = '我的应用';
  @LocalStorageProp('count') count: number = 0;
  @LocalStorageProp('hasData') hasData: boolean = false;
  
  build() {
    Column({ space: 12 }) {
      // 标题区域
      Text(this.title)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
      
      // 数据展示区域
      if (this.hasData) {
        Text(this.count.toString())
          .fontSize(32)
          .fontColor(Color.White)
      } else {
        Text('暂无数据')
          .fontSize(14)
          .fontColor('rgba(255,255,255,0.8)')
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#667eea')
    .borderRadius(16)
    .onClick(() => {
      // 点击卡片跳转到应用
      postCardAction(this, {
        action: 'router',
        abilityName: 'EntryAbility',
        params: { page: 'home' }
      });
    })
  }
}

代码说明:

  • @LocalStorageProp - 卡片专用的数据绑定装饰器,自动接收 formBindingData 传递的数据
  • 变量名(如 titlecount)必须与 FormDataService.getFormData() 返回对象的键名完全一致
  • postCardAction - 卡片专用的交互 API,支持 router(跳转应用)和 message(发送事件)两种 action
  • 卡片 UI 使用 ArkTS 声明式语法,与 Flutter 的 Widget 概念类似

第五步:实现 Flutter 与原生通信

5.1 创建原生通信插件

ohos/entry/src/main/ets/entryability/ 目录下创建 FormWidgetPlugin.ets

import { FlutterPlugin, MethodCall, MethodChannel, MethodResult } from '@ohos/flutter_ohos';
import { FlutterPluginBinding } from '@ohos/flutter_ohos/src/main/ets/embedding/engine/plugins/FlutterPlugin';
import { FormDataService } from '../services/FormDataService';

export default class FormWidgetPlugin implements FlutterPlugin {
  private channel?: MethodChannel;
  
  // Flutter 引擎加载完成后调用
  onAttachedToEngine(binding: FlutterPluginBinding): void {
    // 创建方法通道,名称必须与 Flutter 侧一致
    this.channel = new MethodChannel(binding.getBinaryMessenger(), 'com.example.app/form_widget');
    
    this.channel.setMethodCallHandler({
      onMethodCall: (call: MethodCall, result: MethodResult) => {
        switch (call.method) {
          case 'updateWidgetData':
            this.handleUpdateWidgetData(call, result);
            break;
          case 'refreshAllWidgets':
            FormDataService.getInstance().updateAllForms().then(() => result.success(true));
            break;
          default:
            result.notImplemented();
        }
      }
    });
  }
  
  // 处理 Flutter 传来的更新数据请求
  private async handleUpdateWidgetData(call: MethodCall, result: MethodResult): Promise<void> {
    const service = FormDataService.getInstance();
    const title = call.argument('title') as string;
    const count = call.argument('count') as number;
    
    if (title) await service.saveData('widget_title', title);
    if (count !== undefined) await service.saveData('widget_count', count.toString());
    
    result.success(true);
  }
  
  onDetachedFromEngine(binding: FlutterPluginBinding): void {
    this.channel?.setMethodCallHandler(null);
  }
  
  getUniqueClassName(): string { return 'FormWidgetPlugin'; }
}

代码说明:

  • MethodChannel - Flutter 与原生通信的核心类,通过唯一名称建立双向通道
  • onMethodCall - 接收 Flutter 发来的方法调用,根据 call.method 分发处理
  • call.argument() - 获取 Flutter 传递的参数
  • result.success() - 异步返回结果给 Flutter
  • 通道名称 com.example.app/form_widget 必须与 Flutter 侧完全一致

5.2 注册插件到应用入口

修改 ohos/entry/src/main/ets/entryability/EntryAbility.ets

import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import FormWidgetPlugin from './FormWidgetPlugin';
import { FormDataService } from '../services/FormDataService';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends FlutterAbility {
  
  onWindowStageCreate(windowStage: window.WindowStage): void {
    super.onWindowStageCreate(windowStage);
    // 初始化卡片数据服务的本地存储
    FormDataService.getInstance().initPreferences(this.context);
  }
  
  configureFlutterEngine(flutterEngine: FlutterEngine): void {
    super.configureFlutterEngine(flutterEngine);
    GeneratedPluginRegistrant.registerWith(flutterEngine);
    // 注册自定义的卡片通信插件
    this.addPlugin(new FormWidgetPlugin());
  }
}

代码说明:

  • onWindowStageCreate - 窗口创建时初始化数据服务,确保首选项存储可用
  • configureFlutterEngine - Flutter 引擎配置回调,在此注册所有原生插件
  • this.addPlugin() - 将自定义插件添加到 Flutter 引擎中

5.3 Flutter 侧实现通信服务

lib/services/ 目录下创建 form_widget_service.dart

import 'package:flutter/services.dart';

class FormWidgetService {
  // 通道名称必须与原生侧完全一致
  static const MethodChannel _channel = MethodChannel('com.example.app/form_widget');
  
  static final FormWidgetService instance = FormWidgetService._();
  FormWidgetService._();
  
  /// 更新卡片数据
  Future<bool> updateWidgetData({String? title, int? count}) async {
    try {
      final result = await _channel.invokeMethod<bool>('updateWidgetData', {
        if (title != null) 'title': title,
        if (count != null) 'count': count,
      });
      return result ?? false;
    } on PlatformException catch (e) {
      print('更新卡片失败: ${e.message}');
      return false;
    }
  }
  
  /// 刷新所有卡片
  Future<bool> refreshAllWidgets() async {
    try {
      return await _channel.invokeMethod<bool>('refreshAllWidgets') ?? false;
    } on PlatformException {
      return false;
    }
  }
}

代码说明:

  • MethodChannel - Flutter 侧的通道类,与原生侧建立通信
  • invokeMethod - 调用原生方法,第一个参数是方法名,第二个参数是传递的数据
  • 使用单例模式确保全局只有一个服务实例
  • 异常处理确保原生调用失败时不会导致应用崩溃

第六步:在 Flutter 应用中使用

在业务代码中调用服务更新卡片:

import 'services/form_widget_service.dart';

class MyHomePage extends StatefulWidget {
  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _widgetService = FormWidgetService.instance;
  int _count = 0;
  
  // 添加数据后同步更新卡片
  Future<void> _addItem() async {
    setState(() => _count++);
    
    // 调用服务更新桌面卡片
    await _widgetService.updateWidgetData(
      title: '我的待办',
      count: _count,
    );
  }
  
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('数量: $_count')),
      floatingActionButton: FloatingActionButton(
        onPressed: _addItem,
        child: Icon(Icons.add),
      ),
    );
  }
}

代码说明:

  • 在数据变化时调用 updateWidgetData 同步更新桌面卡片
  • 卡片更新是异步操作,不会阻塞 UI
  • 建议在关键业务操作(如添加、删除、修改数据)后调用更新

常见问题排查

问题 1:卡片无法添加到桌面

检查步骤:

  1. 确认 module.json5 中已正确注册 FormExtensionAbility
  2. 确认 form_config.json 文件路径和内容正确
  3. 确认 metadata 中的 resource 指向正确的配置文件

问题 2:卡片数据不更新

检查步骤:

  1. 确认 @LocalStorageProp 的变量名与数据服务返回的键名完全一致
  2. 确认 formProvider.updateForm 被正确调用
  3. 检查 formId 是否正确传递和存储

问题 3:Platform Channel 通信失败

检查步骤:

  1. 确认 Flutter 侧和原生侧的通道名称完全一致(区分大小写)
  2. 确认插件已在 EntryAbility 中注册
  3. 确认方法名在两端一致

问题 4:卡片点击无响应

检查步骤:

  1. 确认 postCardAction 中的 abilityName 是已注册的 Ability 名称
  2. 确认 action 类型正确(routermessage

最佳实践

1. 数据更新防抖

避免频繁更新卡片,建议使用防抖机制:

Timer? _debounceTimer;

void scheduleWidgetUpdate() {
  _debounceTimer?.cancel();
  _debounceTimer = Timer(Duration(milliseconds: 500), () {
    FormWidgetService.instance.refreshAllWidgets();
  });
}

2. 错误处理

原生侧处理卡片已被删除的情况:

try {
  await formProvider.updateForm(formId, bindingData);
} catch (err) {
  if ((err as BusinessError).code === 16501001) {
    this.unregisterForm(formId);  // 卡片已删除,清理 ID
  }
}

3. 性能优化建议

  • 卡片 UI 中避免复杂计算,数据处理放在 FormDataService
  • 合理设置 updateDuration,避免过于频繁的定时更新
  • 使用缓存减少重复的数据库查询

参考资料

  • HarmonyOS Form Kit 官方文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/form-overview
  • Flutter for OpenHarmony 官方仓库:https://gitee.com/openharmony-sig/flutter_samples
  • ArkTS 开发指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-get-started
Logo

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

更多推荐