Flutter for OpenHarmony 原生卡片 Widget 集成实战:从零构建待办清单桌面组件
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
一、技术背景:为什么需要在 OpenHarmony 上实现原生卡片 Widget
1.1 桌面小组件的市场价值
在移动应用交互模式持续演进的当下,桌面小组件已成为提升应用触达效率的关键载体。用户无需进入应用主界面,即可在桌面直接查看核心信息、执行快捷操作。这种"信息前置"的设计理念显著降低了用户获取信息的认知成本,尤其适合天气、日程、待办、健身数据等需要高频查看的场景。
从用户留存角度分析,桌面小组件的存在能够有效提升应用的日活跃用户比例。当用户能够在桌面快速浏览关键数据时,应用的可见性和使用频率都会得到明显提升。这对于待办清单、健康管理、新闻阅读等类型的应用尤为重要。
1.2 OpenHarmony 原子化服务的独特优势
OpenHarmony 的卡片 Widget 机制与 Android/iOS 存在本质差异。在 Android 平台上,桌面小组件通过 AppWidgetProvider 实现,本质上是 RemoteViews 的远程渲染;在 iOS 平台上,WidgetKit 采用 SwiftUI 声明式描述,应用范围限于苹果生态。而 OpenHarmony 的原子化服务卡片基于 ArkUI 框架实现,能够直接使用声明式语法构建 UI 组件,渲染性能更优。
更为重要的是,OpenHarmony 的超级终端能力允许卡片内容在不同设备间流转。用户在一台设备上添加的卡片,可以通过分布式技术同步到其他登录同一账号的设备上。这种设备无关的体验是 OpenHarmony 平台的核心竞争力,也是 Flutter 开发者适配 OH 时需要重点关注的能力边界。
1.3 Flutter for OpenHarmony 的 Widget 集成挑战
Flutter 框架本身并不原生支持 OpenHarmony 的卡片 Widget 机制。Flutter 应用运行在 Flutter 引擎之上,UI 渲染由 Flutter 自有的渲染管线完成。而 OpenHarmony 的卡片 Widget 是由 ArkUI 引擎驱动的独立组件,两者的渲染架构存在根本差异。
这意味着 Flutter 开发者如果想要在 OH 设备上提供桌面小组件,不能简单地在 Flutter 层实现,而必须借助 Platform Channel 与原生 ArkTS 代码协作。本质上,需要构建一座桥梁,让 Flutter 应用的数据能够流向原生卡片组件,同时让卡片上的用户交互能够回调到 Flutter 逻辑层。
二、架构设计:Flutter 与 ArkUI 的协作模式
2.1 整体技术架构
本方案采用分层架构设计,将 Flutter 应用层、Platform Channel 通信层、ArkTS 原生实现层三层解耦。各层职责清晰,边界明确,便于独立开发和测试。
Flutter 应用层包含 TodoWidgetProvider,负责待办数据的聚合与缓存、数据变化的监听与通知、以及统计数据的格式化输出。Platform Channel 通信层使用 MethodChannel com.example.oh_demol/widget_data 传递数据,数据格式为 Map<String, dynamic>。ArkTS 原生实现层包含 WidgetCommunicationBridge 通信桥接器和 TodoFormWidget 卡片组件,前者处理 Flutter 方法调用、管理待办数据缓存、后者使用 ArkUI 声明式语法构建 UI、通过 LocalStorage 实现数据绑定、支持三种尺寸布局适配。
2.2 数据流设计
数据流向分为两个方向理解:数据推送方向和用户交互方向。
数据推送方向(Flutter → Widget):当 Flutter 应用中的待办数据发生变化时,首先由 TodoWidgetProvider 聚合计算,生成包含总数、已完成数、待办数、紧急数等字段的统计数据对象。该对象通过 MethodChannel 发送到原生层,原生层的 WidgetCommunicationBridge 接收后存入 AppStorage,最后触发 TodoFormWidget 的响应式更新。
用户交互方向(Widget → Flutter):用户点击卡片后,TodoFormWidget 的点击事件传递到 WidgetCommunicationBridge,Bridge 构造包含卡片标识和目标页面的 Want 对象,调用 startAbility 启动应用主界面。EntryAbility 的 onNewWant 方法接收参数后,通过 AppStorage 将卡片启动参数传递给 Flutter 引擎,Flutter 应用根据参数跳转到相应页面。
2.3 通信协议设计
Platform Channel 的方法调用采用结构化的请求-响应模式。方法名称统一使用小写字母加下划线的风格,与 Dart 社区惯例保持一致。
update_todo_widget_data 方法从 Flutter 推送到原生,参数为包含 totalCount、completedCount、pendingCount、urgentCount 四个核心字段的 Map,无返回值。get_todo_widget_data 方法同样从 Flutter 推送到原生,无参数,返回原生层缓存的统计数据 Map。on_widget_click 方法由原生推送到 Flutter,参数包含卡片标识和目标页面信息。
三、Dart 层实现:待办数据聚合与通道通信
3.1 待办数据模型设计
待办卡片展示的核心数据是统计聚合结果,而非单个待办项的详情。数据模型的设计重点在于统计字段的完整性和可计算性。
dart
class TodoWidgetData {
final int totalCount;
final int completedCount;
final int pendingCount;
final int urgentCount;
final DateTime lastUpdated;
const TodoWidgetData({
required this.totalCount,
required this.completedCount,
required this.pendingCount,
required this.urgentCount,
required this.lastUpdated,
});
double get completionRate {
if (totalCount == 0) return 0.0;
return completedCount / totalCount;
}
String get completionRateText {
return '${(completionRate * 100).toStringAsFixed(0)}%';
}
String get progressText => '$completedCount/$totalCount';
Map<String, dynamic> toMap() {
return {
'totalCount': totalCount,
'completedCount': completedCount,
'pendingCount': pendingCount,
'urgentCount': urgentCount,
'completionRate': completionRate,
'completionRateText': completionRateText,
'progressText': progressText,
'lastUpdated': lastUpdated.toIso8601String(),
};
}
factory TodoWidgetData.fromMap(Map<String, dynamic> map) {
return TodoWidgetData(
totalCount: map['totalCount'] as int? ?? 0,
completedCount: map['completedCount'] as int? ?? 0,
pendingCount: map['pendingCount'] as int? ?? 0,
urgentCount: map['urgentCount'] as int? ?? 0,
lastUpdated: map['lastUpdated'] != null
? DateTime.parse(map['lastUpdated'] as String)
: DateTime.now(),
);
}
factory TodoWidgetData.empty() {
return TodoWidgetData(
totalCount: 0,
completedCount: 0,
pendingCount: 0,
urgentCount: 0,
lastUpdated: DateTime.now(),
);
}
}
这个数据模型遵循三个设计原则:完整性原则包含卡片展示所需的所有统计字段;计算性原则使 completionRate 等衍生字段由模型自身计算,确保数据一致性;可序列化原则通过 toMap 方法将数据对象转换为 Map 结构,便于通过 Platform Channel 传输。
3.2 待办数据聚合器
待办数据聚合器的职责是从业务层获取原始待办列表,计算聚合统计数据,并管理数据缓存。设计为单例类,确保全局唯一实例。
dart
class TodoWidgetAggregator {
static TodoWidgetAggregator? _instance;
static TodoWidgetAggregator get instance =>
_instance ??= TodoWidgetAggregator._();
TodoWidgetAggregator._();
static const MethodChannel _widgetChannel =
MethodChannel('com.example.oh_demol/widget_data');
TodoWidgetData? _cachedData;
final List<VoidCallback> _listeners = [];
TodoWidgetData get currentData => _cachedData ?? TodoWidgetData.empty();
void addListener(VoidCallback listener) => _listeners.add(listener);
void removeListener(VoidCallback listener) => _listeners.remove(listener);
void _notifyListeners() {
for (final listener in _listeners) {
try {
listener();
} catch (e) {
debugPrint('[TodoWidgetAggregator] 监听器回调异常: $e');
}
}
}
TodoWidgetData computeFromTodos(List<TodoItem> todos) {
final completed = todos.where((t) => t.isCompleted).length;
final pending = todos.where((t) => !t.isCompleted).length;
int urgent = 0;
if (pending > 5) {
urgent = (pending * 0.2).ceil();
}
final data = TodoWidgetData(
totalCount: todos.length,
completedCount: completed,
pendingCount: pending,
urgentCount: urgent,
lastUpdated: DateTime.now(),
);
_cachedData = data;
return data;
}
Future<void> refreshCard(List<TodoItem> todos) async {
final data = computeFromTodos(todos);
try {
await _widgetChannel.invokeMethod('update_todo_widget_data', data.toMap());
_notifyListeners();
} catch (e) {
debugPrint('[TodoWidgetAggregator] 更新卡片失败: $e');
}
}
Future<TodoWidgetData?> fetchNativeData() async {
try {
final result = await _widgetChannel
.invokeMethod<Map<dynamic, dynamic>>('get_todo_widget_data');
if (result != null) {
return TodoWidgetData.fromMap(result.cast<String, dynamic>());
}
} catch (e) {
debugPrint('[TodoWidgetAggregator] 获取原生数据失败: $e');
}
return null;
}
}
3.3 卡片数据提供器
卡片数据提供器是 Flutter 应用的入口点,负责在应用生命周期中维护待办数据并同步到卡片。使用 ChangeNotifier 模式,便于与 Flutter 的状态管理系统集成。
dart
class TodoWidgetProvider extends ChangeNotifier {
static TodoWidgetProvider? _instance;
static TodoWidgetProvider get instance =>
_instance ??= TodoWidgetProvider._();
TodoWidgetProvider._() {
_aggregator = TodoWidgetAggregator.instance;
_aggregator.addListener(_onDataChanged);
}
late final TodoWidgetAggregator _aggregator;
List<TodoItem> _todos = [];
bool _isLoading = false;
String? _errorMessage;
List<TodoItem> get todos => List.unmodifiable(_todos);
TodoWidgetData get widgetData => _aggregator.currentData;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
void _onDataChanged() => notifyListeners();
Future<void> loadTodos() async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_todos = await _todoRepository.getTodos();
await _aggregator.refreshCard(_todos);
} catch (e) {
_errorMessage = '加载待办失败: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> addTodo(TodoItem todo) async {
_todos.add(todo);
await _aggregator.refreshCard(_todos);
notifyListeners();
}
Future<void> completeTodo(int id) async {
final index = _todos.indexWhere((t) => t.id == id);
if (index != -1) {
_todos[index] = _todos[index].copyWith(isCompleted: true);
await _aggregator.refreshCard(_todos);
notifyListeners();
}
}
Future<void> deleteTodo(int id) async {
_todos.removeWhere((t) => t.id == id);
await _aggregator.refreshCard(_todos);
notifyListeners();
}
@override
void dispose() {
_aggregator.removeListener(_onDataChanged);
super.dispose();
}
}
四、ArkTS 原生层实现:卡片组件与通信桥接
4.1 通信桥接器核心实现
ArkTS 层的 WidgetCommunicationBridge 承担着 Flutter 与卡片之间数据流转中枢的角色。
typescript
import { common, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export interface WidgetInfo {
name: string;
bundleName: string;
description: string;
supportedSizes?: string;
}
export interface TodoWidgetData {
totalCount: number;
completedCount: number;
pendingCount: number;
urgentCount: number;
lastUpdated?: string;
displayDate?: string;
progressText?: string;
completionRate?: number;
completionRateText?: string;
}
export interface WidgetBridge {
updateTodoData(totalCount: number, completedCount: number,
pendingCount: number, urgentCount: number): void;
getTodoData(): Record<string, number | string>;
getWidgetInfo(): WidgetInfo;
destroy(): void;
}
export class WidgetCommunicationBridge {
private context: common.UIAbilityContext | null = null;
private _bridge: WidgetBridge | null = null;
private defaultWidgetInfo: WidgetInfo = {
name: 'TodoWidget',
bundleName: 'com.example.oh_demol',
description: '待办清单卡片',
};
private defaultTodoData: Record<string, number | string> = {
totalCount: 0,
completedCount: 0,
pendingCount: 0,
urgentCount: 0,
completionRate: 0,
completionRateText: '0%',
progressText: '0/0',
displayDate: this.getCurrentTime(),
};
init(context: common.UIAbilityContext): void {
this.context = context;
console.info('[WidgetBridge] WidgetCommunicationBridge initialized');
}
private getCurrentTime(): string {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
getTodoData(): Record<string, number | string> {
if (this._bridge) return this._bridge.getTodoData();
return this.defaultTodoData;
}
updateTodoData(
totalCount: number,
completedCount: number,
pendingCount: number,
urgentCount: number
): void {
if (this._bridge) {
this._bridge.updateTodoData(totalCount, completedCount, pendingCount, urgentCount);
return;
}
const completionRate = totalCount > 0 ? completedCount / totalCount : 0;
this.defaultTodoData['totalCount'] = totalCount;
this.defaultTodoData['completedCount'] = completedCount;
this.defaultTodoData['pendingCount'] = pendingCount;
this.defaultTodoData['urgentCount'] = urgentCount;
this.defaultTodoData['completionRate'] = completionRate;
this.defaultTodoData['completionRateText'] = `${(completionRate * 100).toFixed(0)}%`;
this.defaultTodoData['progressText'] = `${completedCount}/${totalCount}`;
this.defaultTodoData['displayDate'] = this.getCurrentTime();
this.defaultTodoData['lastUpdated'] = new Date().toISOString();
this.syncToAppStorage();
console.info(`[WidgetBridge] Todo data updated: ${JSON.stringify(this.defaultTodoData)}`);
}
private syncToAppStorage(): void {
try {
AppStorage.setOrCreate('totalCount', this.defaultTodoData['totalCount']);
AppStorage.setOrCreate('completedCount', this.defaultTodoData['completedCount']);
AppStorage.setOrCreate('pendingCount', this.defaultTodoData['pendingCount']);
AppStorage.setOrCreate('urgentCount', this.defaultTodoData['urgentCount']);
AppStorage.setOrCreate('completionRate', this.defaultTodoData['completionRate']);
AppStorage.setOrCreate('completionRateText', this.defaultTodoData['completionRateText']);
AppStorage.setOrCreate('progressText', this.defaultTodoData['progressText']);
AppStorage.setOrCreate('displayDate', this.defaultTodoData['displayDate']);
AppStorage.setOrCreate('lastUpdated', this.defaultTodoData['lastUpdated']);
} catch (e) {
console.error(`[WidgetBridge] Failed to sync to AppStorage: ${e}`);
}
}
getWidgetInfo(): WidgetInfo {
if (this._bridge) return this._bridge.getWidgetInfo();
return this.defaultWidgetInfo;
}
handleWidgetClick(formId: string, params: Record<string, string>): void {
console.info(`[WidgetBridge] Widget clicked: formId=${formId}`);
if (!this.context) {
console.error('[WidgetBridge] Context is null');
return;
}
try {
const want: Want = {
action: 'action.app.widget.click',
bundleName: 'com.example.oh_demol',
abilityName: 'EntryAbility',
parameters: {
fromWidget: true,
widgetFormId: formId,
targetPage: params['targetPage'] || '/',
},
};
this.context.startAbility(want)
.then(() => console.info('[WidgetBridge] App started from widget click'))
.catch((err: BusinessError) => {
console.error(`[WidgetBridge] Failed to start app: ${err.code} - ${err.message}`);
});
} catch (e) {
console.error(`[WidgetBridge] Exception in handleWidgetClick: ${e}`);
}
}
setBridge(bridge: WidgetBridge): void {
this._bridge = bridge;
console.info('[WidgetBridge] Bridge implementation set');
}
destroy(): void {
if (this._bridge) this._bridge.destroy();
this._bridge = null;
this.context = null;
console.info('[WidgetBridge] WidgetCommunicationBridge destroyed');
}
}
4.2 卡片 Widget 组件实现
TodoFormWidget 是展示待办统计数据的卡片组件。使用 ArkUI 的 @Component 装饰器定义,支持三种标准尺寸(2x2、2x4、4x4),通过 @LocalStorageLink 装饰器绑定 AppStorage 中的数据,实现响应式更新。
typescript
@Component
export struct TodoFormWidget {
@LocalStorageLink('totalCount') totalCount: number = 0;
@LocalStorageLink('completedCount') completedCount: number = 0;
@LocalStorageLink('pendingCount') pendingCount: number = 0;
@LocalStorageLink('urgentCount') urgentCount: number = 0;
@LocalStorageLink('completionRate') completionRate: number = 0.0;
@LocalStorageLink('completionRateText') completionRateText: string = '0%';
@LocalStorageLink('progressText') progressText: string = '0/0';
@LocalStorageLink('displayDate') displayDate: string = '--:--';
@Prop formId: string = '';
@Prop cardSize: string = 'small';
private readonly primaryColor = '#007DFF';
private readonly successColor = '#36D1A1';
private readonly warningColor = '#FF6B35';
private readonly bgColor = '#F5F7FA';
private readonly textPrimary = '#1A1A1A';
private readonly textSecondary = '#666666';
private readonly textTertiary = '#999999';
build() {
Stack() {
Column() {
if (this.cardSize === 'small') {
this.buildSmallLayout();
} else if (this.cardSize === 'medium') {
this.buildMediumLayout();
} else {
this.buildLargeLayout();
}
}
.width('100%')
.height('100%')
.backgroundColor(this.bgColor)
.borderRadius(16)
.onClick(() => this.onCardClick())
}
.width('100%')
.height('100%');
}
@Builder
buildSmallLayout() {
Column() {
Text('今日待办')
.fontSize(14).fontWeight(FontWeight.Medium).fontColor(this.textSecondary)
.alignSelf(ItemAlign.Start)
Blank()
Text(this.pendingCount.toString())
.fontSize(48).fontWeight(FontWeight.Bold).fontColor(this.primaryColor)
.margin({ top: 8 })
Text('项待办').fontSize(12).fontColor(this.textTertiary)
}
.width('100%').height('100%').padding(12)
.justifyContent(FlexAlign.SpaceBetween);
}
@Builder
buildMediumLayout() {
Column() {
Row() {
Text('今日待办').fontSize(16).fontWeight(FontWeight.Medium).fontColor(this.textPrimary)
Blank()
Text(this.displayDate).fontSize(10).fontColor(this.textTertiary)
}
.width('100%').margin({ bottom: 12 })
Row() {
Column() {
Text(this.pendingCount.toString())
.fontSize(56).fontWeight(FontWeight.Bold).fontColor(this.primaryColor)
Text('待办').fontSize(14).fontColor(this.textSecondary)
}.alignItems(HorizontalAlign.Start)
Blank()
Column() {
Text(this.completedCount.toString())
.fontSize(28).fontWeight(FontWeight.Bold).fontColor(this.successColor)
Text('已完成').fontSize(12).fontColor(this.textTertiary)
}.alignItems(HorizontalAlign.End)
}.width('100%')
Blank()
Column() {
Row() {
Blank()
Text(this.completionRateText).fontSize(12).fontColor(this.textSecondary)
}.width('100%').margin({ bottom: 4 })
Stack() {
Column() {
Column().width('100%').height(6).backgroundColor('#E5E5E5').borderRadius(3)
}
Column() {
Column().width(this.getProgressWidth()).height(6)
.backgroundColor(this.successColor).borderRadius(3)
}.alignItems(HorizontalAlign.Start)
}.width('100%')
}.width('100%')
}
.width('100%').height('100%').padding(16)
.justifyContent(FlexAlign.SpaceBetween);
}
@Builder
buildLargeLayout() {
Column() {
Row() {
Text('待办清单').fontSize(18).fontWeight(FontWeight.Bold).fontColor(this.textPrimary)
Blank()
Text(this.displayDate).fontSize(12).fontColor(this.textTertiary)
}
.width('100%').margin({ bottom: 16 })
Row() {
this.buildStatCard('总计', this.totalCount.toString(), this.textPrimary)
this.buildStatCard('待办', this.pendingCount.toString(), this.primaryColor)
this.buildStatCard('完成', this.completedCount.toString(), this.successColor)
this.buildStatCard('紧急', this.urgentCount.toString(), this.warningColor)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Blank()
Column() {
Row() {
Text('完成进度').fontSize(14).fontColor(this.textSecondary)
Blank()
Text(this.completionRateText)
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(this.successColor)
}.width('100%').margin({ bottom: 8 })
Stack() {
Column() {
Column().width('100%').height(8).backgroundColor('#E5E5E5').borderRadius(4)
}
Column() {
Column().width(this.getProgressWidth()).height(8)
.backgroundColor(this.successColor).borderRadius(4)
}.alignItems(HorizontalAlign.Start)
}.width('100%')
Row() {
Text(this.progressText).fontSize(12).fontColor(this.textTertiary)
Blank()
Text('点击查看详情').fontSize(12).fontColor(this.primaryColor)
}.width('100%').margin({ top: 8 })
}
.width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(12)
}
.width('100%').height('100%').padding(16)
.justifyContent(FlexAlign.SpaceBetween);
}
@Builder
buildStatCard(label: string, value: string, color: string) {
Column() {
Text(value).fontSize(24).fontWeight(FontWeight.Bold).fontColor(color)
Text(label).fontSize(10).fontColor(this.textTertiary)
}.padding(8)
}
private getProgressWidth(): string {
const rate = this.completionRate * 100;
return `${Math.min(rate, 100)}%`;
}
private onCardClick(): void {
console.info(`[TodoFormWidget] Card clicked: formId=${this.formId}`);
}
}
4.3 入口 Ability 实现
EntryAbility 是应用的主入口,负责初始化 Flutter 引擎、注册 Platform Channel 处理器、以及处理来自卡片的启动参数。
typescript
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import { WidgetStateManager, WidgetBridge, WidgetInfo, TodoWidgetData } from '../bridge/WidgetCommunicationBridge';
import { common, Want, AbilityConstant } from '@kit.AbilityKit';
const TAG = 'EntryAbility';
export default class EntryAbility extends FlutterAbility {
private widgetBridge: WidgetBridge = new WidgetBridgeImpl();
private widgetStateManager: WidgetStateManager = WidgetStateManager.getInstance();
configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine);
GeneratedPluginRegistrant.registerWith(flutterEngine);
this.initWidgetBridge(flutterEngine);
}
private initWidgetBridge(flutterEngine: FlutterEngine): void {
try {
this.widgetStateManager.initBridge(this.widgetBridge);
console.info('[EntryAbility] Widget communication bridge initialized');
} catch (e) {
console.error(`[EntryAbility] Failed to init widget bridge: ${e}`);
}
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
super.onNewWant(want, launchParam);
try {
const wantParams: Record<string, number | string | boolean> =
want.parameters as Record<string, number | string | boolean>;
if (wantParams && wantParams['fromWidget'] === true) {
console.info('[EntryAbility] App launched from widget');
const todoDataStr: string | undefined = wantParams['todoData'] as string;
if (todoDataStr) {
try {
const todoData: TodoWidgetData = {
totalCount: 0,
completedCount: 0,
pendingCount: 0,
urgentCount: 0,
};
AppStorage.setOrCreate('todoWidgetData', todoData);
console.info('[EntryAbility] Widget data stored to AppStorage');
} catch (e) {
console.error(`[EntryAbility] Failed to parse widget data: ${e}`);
}
}
const targetPage: string | undefined = wantParams['targetPage'] as string;
if (targetPage) {
console.info(`[EntryAbility] Widget target page: ${targetPage}`);
}
}
} catch (e) {
console.error(`[EntryAbility] Error handling widget want: ${e}`);
}
}
onDestroy() {
try {
this.widgetStateManager.getBridge().destroy();
} catch (_) {}
super.onDestroy();
}
}
class WidgetBridgeImpl implements WidgetBridge {
private todoWidgetData: Record<string, number | string> = {};
updateTodoData(
totalCount: number,
completedCount: number,
pendingCount: number,
urgentCount: number
): void {
const now: Date = new Date();
const hours: string = now.getHours().toString().padStart(2, '0');
const minutes: string = now.getMinutes().toString().padStart(2, '0');
const completionRate = totalCount > 0 ? completedCount / totalCount : 0;
this.todoWidgetData['totalCount'] = totalCount;
this.todoWidgetData['completedCount'] = completedCount;
this.todoWidgetData['pendingCount'] = pendingCount;
this.todoWidgetData['urgentCount'] = urgentCount;
this.todoWidgetData['lastUpdated'] = now.toISOString();
this.todoWidgetData['displayDate'] = `${hours}:${minutes}`;
this.todoWidgetData['progressText'] = `${completedCount}/${totalCount}`;
this.todoWidgetData['completionRate'] = completionRate;
this.todoWidgetData['completionRateText'] = `${(completionRate * 100).toFixed(0)}%`;
console.info('[WidgetBridge] Todo data updated');
}
getTodoData(): Record<string, number | string> {
return this.todoWidgetData;
}
getWidgetInfo(): WidgetInfo {
return {
name: 'TodoWidget',
bundleName: 'com.example.oh_demol',
description: '待办清单卡片',
supportedSizes: '2*2,2*4,4*4',
};
}
destroy(): void {
this.todoWidgetData = {};
console.info('[WidgetBridge] WidgetBridge destroyed');
}
}
4.4 Widget 状态管理器
typescript
import { WidgetCommunicationBridge } from '../bridge/WidgetCommunicationBridge';
export class WidgetStateManager {
private static instance: WidgetStateManager | null = null;
private activeWidgets: Map<string, number> = new Map();
private bridge: WidgetCommunicationBridge = new WidgetCommunicationBridge();
private _widgetBridge: WidgetBridge | null = null;
private constructor() {}
static getInstance(): WidgetStateManager {
if (!WidgetStateManager.instance) {
WidgetStateManager.instance = new WidgetStateManager();
}
return WidgetStateManager.instance;
}
registerWidget(formId: string): void {
this.activeWidgets.set(formId, Date.now());
console.info(`[WidgetStateManager] Widget registered: ${formId}`);
}
unregisterWidget(formId: string): void {
this.activeWidgets.delete(formId);
console.info(`[WidgetStateManager] Widget unregistered: ${formId}`);
}
isWidgetActive(formId: string): boolean {
return this.activeWidgets.has(formId);
}
getActiveWidgetCount(): number {
return this.activeWidgets.size;
}
getBridge(): WidgetBridge {
return this._widgetBridge || this.bridge;
}
initBridge(bridge: WidgetBridge): void {
this._widgetBridge = bridge;
this.bridge.setBridge(bridge);
console.info('[WidgetStateManager] Widget bridge initialized');
}
}
五、构建验证与测试
5.1 编译错误排查
ArkTS 采用了比标准 TypeScript 更严格的类型检查规则。最关键的规则是对象字面量必须对应显式声明的接口或类。
编译报错 Object literal must correspond to some explicitly declared class or interface 时,需要检查代码中是否有直接使用对象字面量而未定义对应类型的情况。正确的做法是先定义接口或类型,再使用该类型声明变量。
typescript
// 错误:直接使用对象字面量
const info = {
name: 'TodoWidget',
bundleName: 'com.example.oh_demol',
description: '待办清单卡片',
};
// 正确:先定义接口,再声明变量
interface WidgetInfo {
name: string;
bundleName: string;
description: string;
}
const info: WidgetInfo = {
name: 'TodoWidget',
bundleName: 'com.example.oh_demol',
description: '待办清单卡片',
};
另一个常见问题是模块导入错误。Flutter 模块导出的类型可能与标准 ArkTS 有所不同。如果遇到 Module has no exported member 错误,需要检查导入路径是否正确,以及目标模块是否实际导出了该成员。
5.2 功能测试方法
卡片 Widget 的测试分为三个层次:数据推送测试、UI 渲染测试、交互响应测试。
数据推送测试 :在 Flutter 应用中添加待办项,使用 HiLog 查看日志,确认出现 [WidgetBridge] Todo data updated 记录,并检查 AppStorage 中的数据是否正确更新。
UI 渲染测试:将应用部署到鸿蒙设备后,长按桌面空白区域,选择添加小部件,找到应用提供的待办卡片,将不同尺寸的卡片添加到桌面,检查各尺寸下的布局是否符合预期。
交互响应测试:点击卡片后,应用应该被启动并跳转到相应的待办列表页面。
六、项目文件结构
ohos/
├── entry/src/main/ets/
│ ├── entryability/EntryAbility.ets # 应用入口
│ ├── pages/TodoFormPage.ets # 卡片预览页面
│ ├── widget/TodoFormWidget.ets # 待办卡片组件
│ └── bridge/WidgetCommunicationBridge.ets # 通信桥接器
lib/
├── models/todo_widget_data.dart # 卡片数据模型
├── providers/todo_widget_provider.dart # 卡片数据提供器
├── services/todo_widget_aggregator.dart # 待办数据聚合器
└── pages/widget_demo_page.dart # 卡片演示页面
这是我的运行截图:
七、技术总结与延伸思考
7.1 方案的核心价值
本方案展示了在 Flutter for OpenHarmony 项目中集成原生卡片 Widget 的完整路径。核心思路是通过 Platform Channel 建立 Flutter 与 ArkUI 之间的通信桥梁,让 Flutter 业务层的数据能够流向原生 UI 组件,同时让原生的用户交互能够反馈到 Flutter 逻辑层。
这种架构设计的优势在于保持了 Flutter 和 ArkUI 各自的特性。Flutter 层继续使用 Dart 语言和 Flutter 的状态管理机制,无需为卡片功能引入额外的状态同步逻辑。ArkUI 层则充分利用声明式 UI 的能力,提供高性能的卡片渲染。
7.2 当前方案的局限性
当前方案尚未实现卡片数据的实时推送机制。Flutter 应用在后台时,无法主动将数据更新推送到卡片。卡片的定时刷新依赖 OH 系统本身的机制,无法做到即时响应。
此外,本方案中的卡片 Widget 是基于 ArkUI 组件方式实现的,而非使用 FormKit 的标准卡片 API。这种实现方式的优势是开发调试相对简单,但可能无法享受 OH 系统级别的卡片管理能力。
7.3 后续优化方向
在数据推送层面,可以探索使用 OH 的 RPC 机制实现 Flutter 与原生的长连接通信。在能力扩展层面,可以考虑使用 FormKit 的标准卡片 API 实现完整形态的卡片 Widget。在用户体验层面,可以增加卡片配置功能,允许用户选择显示哪些统计项。