跳到内容

在 Flutter 项目中实现 iOS 实时活动

我们将为一个 Flutter 项目创建一个带有秒表实现的实时活动 (Live Activity)。该项目将包括 Flutter 端的实现以及 Xcode 项目中的原生实现。

Flutter 端实现

Anchor link to

我们将创建一个类,该类将实现三个方法:

  • startLiveActivity()
  • updateLiveActivity()
  • stopLiveActivity()
class DynamicIslandManager {
final String channelKey;
late final MethodChannel _methodChannel;
DynamicIslandManager({required this.channelKey}) {
_methodChannel = MethodChannel(channelKey);
}
Future<void> startLiveActivity({required Map<String, dynamic> jsonData}) async {
try {
await _methodChannel.invokeListMethod('startLiveActivity', jsonData);
} catch (e, st) {
log(e.toString(), stackTrace: st);
}
}
Future<void> updateLiveActivity(
{required Map<String, dynamic> jsonData}) async {
try {
await _methodChannel.invokeListMethod('updateLiveActivity', jsonData);
} catch (e, st) {
log(e.toString(), stackTrace: st);
}
}
Future<void> stopLiveActivity() async {
try {
await _methodChannel.invokeListMethod('stopLiveActivity');
} catch (e, st) {
log(e.toString(), stackTrace: st);
}
}
}

我们将构建一个秒表应用,为了将秒表计时器数据传递给原生方法,我们需要创建一个数据模型,并以类似 JSON 的 map 格式将其发送到方法通道调用中。

class DynamicIslandStopwatchDataModel {
final int elapsedSeconds;
DynamicIslandStopwatchDataModel({
required this.elapsedSeconds,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'elapsedSeconds': elapsedSeconds,
};
}
}

每个方法都将包含调用相应原生方法的必要代码。

这是在 Flutter 端运行它所需的初始设置。在此之后,我们实现了一个带有必要方法调用的基本秒表应用 UI。

class _StopWatchScreenState extends State<StopWatchScreen> {
int seconds = 0;
bool isRunning = false;
Timer? timer;
/// channel key is used to send data from flutter to swift side over
/// a unique bridge (link between flutter & swift)
final DynamicIslandManager diManager = DynamicIslandManager(channelKey: 'PW');
void startTimer() {
setState(() {
isRunning = true;
});
// invoking startLiveActivity Method
diManager.startLiveActivity(
jsonData: DynamicIslandStopwatchDataModel(elapsedSeconds: 0).toMap(),
);
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
seconds++;
});
// invoking the updateLiveActivity Method
diManager.updateLiveActivity(
jsonData: DynamicIslandStopwatchDataModel(
elapsedSeconds: seconds,
).toMap(),
);
});
}
void stopTimer() {
timer?.cancel();
setState(() {
seconds = 0;
isRunning = false;
});
// invoking the stopLiveActivity Method
diManager.stopLiveActivity();
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Stopwatch App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Stopwatch: $seconds seconds',
style: const TextStyle(fontSize: 24),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: isRunning ? null : startTimer,
child: const Text('Start'),
),
const SizedBox(width: 20),
ElevatedButton(
onPressed: isRunning ? stopTimer : null,
child: const Text('Stop'),
),
],
),
],
),
),
);
}
}

原生端实现

Anchor link to

Xcode 项目配置

Anchor link to
  1. 在 Xcode 中打开您的项目,并在 “General” 选项卡中将最低部署版本设置为 iOS 16.1。
Xcode “General” 选项卡,显示最低部署版本设置为 iOS 16.1
  1. Info.plist 中添加一个新键 NSSupportsLiveActivities 并将其值设置为 YES (布尔值)。

Info.plist

Anchor link to
<key>NSSupportsLiveActivities</key>
<true/>
  1. 从操作栏中选择 File > New > Target,搜索 WidgetExtension 并创建一个新的 widget。

代码端实现

Anchor link to

让我们创建一个名为 LiveActivityManager 的类来管理我们的灵动岛 (Dynamic Island) 的实时活动。这个类将包含三个方法:startLiveActivity()updateLiveActivity()stopLiveActivity()

Swift

import ActivityKit
import Flutter
import Foundation
@available(iOS 16.1, *)
class LiveActivityManager {
private var stopwatchActivity: Activity<StopwatchWidgetAttributes>? = nil
func startLiveActivity(data: [String: Any]?, result: FlutterResult) {
let attributes = StopwatchWidgetAttributes()
if let info = data {
let state = StopwatchWidgetAttributes.ContentState(
elapsedTime: info["elapsedSeconds"] as? Int ?? 0
)
stopwatchActivity = try? Activity<StopwatchWidgetAttributes>.request(
attributes: attributes, contentState: state, pushType: nil)
} else {
result(FlutterError(code: "418", message: "Live activity didn't invoked", details: nil))
}
}
func updateLiveActivity(data: [String: Any]?, result: FlutterResult) {
if let info = data {
let updatedState = StopwatchWidgetAttributes.ContentState(
elapsedTime: info["elapsedSeconds"] as? Int ?? 0
)
Task {
await stopwatchActivity?.update(using: updatedState)
}
} else {
result(FlutterError(code: "418", message: "Live activity didn't updated", details: nil))
}
}
func stopLiveActivity(result: FlutterResult) {
do {
Task {
await stopwatchActivity?.end(using: nil, dismissalPolicy: .immediate)
}
} catch {
result(FlutterError(code: "418", message: error.localizedDescription, details: nil))
}
}
}

每个方法都有其特定的功能。startLiveActivity() (顾名思义) 负责启动实时活动,从而触发灵动岛的功能。同样,stopLiveActivity()updateLiveActivity() 分别处理停止实时活动和更新灵动岛上显示的数据。

接下来,打开 StopWatchDIWidgetLiveActivity.swift 并修改 StopwatchDIWidgetAttributes 结构体 (如代码片段所示)。这个属性结构体作为数据模型,用于保存要在 UI 上显示的信息 (直接来自 Flutter),并且还可以根据需要修改 UI。

Swift

struct StopwatchWidgetAttributes: ActivityAttributes {
public typealias stopwatchStatus = ContentState
public struct ContentState: Codable, Hashable {
var elapsedTime: Int
}
}

现在,只剩下灵动岛的 UI 了!(太棒了,我们已经走到这一步了!) 整个灵动岛的 UI 都需要使用 SwiftUI 来创建。在本文中,我们设计了一个简单的 UI (但您可以根据需要随意自定义)。

这段 UI 代码应该写在 StopwatchWidgetLiveActivity 结构体内部。删除结构体中现有的代码,然后可以参考下面的代码:

SwiftUI

struct StopwatchWidgetLiveActivity: Widget {
func getTimeString(_ seconds: Int) -> String {
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
let seconds = (seconds % 3600) % 60
return hours == 0 ?
String(format: "%02d:%02d", minutes, seconds)
: String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
var body: some WidgetConfiguration {
ActivityConfiguration(for: StopwatchWidgetAttributes.self) { context in
HStack {
Text("Time ellapsed")
.font(.system(size: 20, weight: .semibold))
.foregroundColor(.white)
Spacer()
Image(systemName: "timer")
.foregroundColor(.white)
Spacer().frame(width: 10)
Text(getTimeString(context.state.elapsedTime))
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.yellow)
}
.padding(.horizontal)
.activityBackgroundTint(Color.black.opacity(0.5))
}
dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .center) {
Text("Pushwoosh Timer")
Spacer().frame(height: 24)
HStack {
Text("Time ellapsed")
.font(.system(size: 20, weight: .semibold))
.foregroundColor(.white)
Spacer()
Image(systemName: "timer")
Spacer().frame(width: 10)
Text(getTimeString(context.state.elapsedTime))
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.yellow)
}.padding(.horizontal)
}
}
} compactLeading: {
Image(systemName: "timer").padding(.leading, 4)
} compactTrailing: {
Text(getTimeString(context.state.elapsedTime)).foregroundColor(.yellow)
.padding(.trailing, 4)
} minimal: {
Image(systemName: "timer")
.foregroundColor(.yellow)
.padding(.all, 4)
}
.widgetURL(URL(string: "http://www.pushwoosh.com"))
.keylineTint(Color.red)
}
}
}

别忘了也要修改 AppDelegate 的代码。

AppDelegate.swift

import UIKit
import Flutter
@main
@objc class AppDelegate: FlutterAppDelegate {
private let liveActivityManager: LiveActivityManager = LiveActivityManager()
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let diChannel = FlutterMethodChannel(name: "PW", binaryMessenger: controller.binaryMessenger)
diChannel.setMethodCallHandler({ [weak self] (
call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "startLiveActivity":
self?.liveActivityManager.startLiveActivity(
data: call.arguments as? Dictionary<String,Any>,
result: result)
break
case "updateLiveActivity":
self?.liveActivityManager.updateLiveActivity(
data: call.arguments as? Dictionary<String,Any>,
result: result)
break
case "stopLiveActivity":
self?.liveActivityManager.stopLiveActivity(result: result)
break
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

就是这样!这涵盖了实现灵动岛并将其链接到 Flutter 代码所需的一切!

Pushwoosh 代码实现

Anchor link to

Pushwoosh Flutter 插件提供了两种在项目中管理实时活动的方法。

/// Default setup Live Activity
Future<void> defaultSetup() async {
await _channel.invokeMethod("defaultSetup");
}
/// Default start Live Activity
/// [activityId] activity ID
/// [attributes] attributes
/// [content] content
Future<void> defaultStart(String activityId, Map<String, dynamic> attributes, Map<String, dynamic> content) async {
await _channel.invokeMethod("defaultStart", {"activityId": activityId, "attributes": attributes, "content": content});
}

defaultSetup() 方法允许您在 Pushwoosh 端管理实时活动的结构和令牌。

在应用程序初始化期间调用此方法。

Pushwoosh.initialize({"app_id": "XXXXX-XXXXX", "sender_id": "XXXXXXXXXXXX"});
/**
* Call this method `defaultSetup()`
*/
Pushwoosh.getInstance.defaultSetup();

在应用初始化期间,Pushwoosh 会将您的 push-to-start 令牌发送到服务器,这之后将允许您通过 API 调用来启动实时活动。 要启动实时活动,您需要发出一个 API 请求。以下是请求的示例:

{
"request": {
"application": "XXXXX-XXXXX",
"auth": "YOUR_AUTH_API_TOKEN",
"notifications": [
{
"content": "Message",
"title":"Title",
"live_activity": {
"event": "start", // `start` event
"content-state": {
"data": { // You need to pass the parameters in a dictionary with the key data.
"emoji": "dynamic data"
}
},
"attributes-type": "DefaultLiveActivityAttributes",
"attributes": {
"data": {
"name": "static data"
}
}
},
"devices": [ "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" // HWID
],
"live_activity_id": "your_activity_id"
}
]
}
}

关于 API 的更详细文档

如果您想从您的应用中启动一个简单的实时活动,您可以在 Flutter 中使用以下方法:defaultStart(String activityId, Map<String, dynamic> attributes, Map<String, dynamic> content)

此方法可以在任何触发实时活动启动的事件上调用。

// Function to start Live Activity
void startLiveActivity() {
// Create your activity ID
String activityId = "stopwatch_activity";
// Define the attributes you want to send to the Live Activity
Map<String, dynamic> attributes = {
'title': 'Stopwatch Activity',
'description': 'This is a live activity for a stopwatch.'
};
// Define the content state to update on the Dynamic Island
Map<String, dynamic> content = {
'elapsedSeconds': 0
};
// Call Pushwoosh's defaultStart method to trigger the Live Activity
Pushwoosh.getInstance().defaultStart(activityId, attributes, content);
}