你好,我是陈航。
在上一篇文章中,我与你分享了如何使用 Provider 去维护 Flutter 组件共用的数据状态。在 Flutter 中状态即数据,通过数据资源封装、注入和读写这三步,我们不仅可以实现跨组件之间的数据共享,还能精确控制 UI 刷新粒度,避免无关组件的刷新。
其实,数据共享不仅存在于客户端内部,同样也存在于服务端与客户端之间。比如,有新的微博评论,或者是发生了重大新闻,我们都需要在服务端把这些状态变更的消息实时推送到客户端,提醒用户有新的内容。有时,我们还会针对特定的用户画像,通过推送实现精准的营销信息触达。
可以说,消息推送是增强用户黏性,促进用户量增长的重要手段。那么,消息推送的流程是什么样的呢?
消息推送流程
手机推送每天那么多,导致在我们看来这很简单啊。但其实,消息推送是一个横跨业务服务器、第三方推送服务托管厂商、操作系统长连接推送服务、用户终端、手机应用五方的复杂业务应用场景。
在 iOS 上,苹果推送服务(APNs)接管了系统所有应用的消息通知需求;而 Android 原生,则提供了类似 Firebase 的云消息传递机制(FCM),可以实现统一的推送托管服务。
当某应用需要发送消息通知时,这则消息会由应用的服务器先发给苹果或 Google,经由 APNs 或 FCM 被发送到设备,设备操作系统在完成解析后,最终把消息转给所属应用。这个流程的示意图,如下所示。
图 1 原生消息推送流程
不过,Google 服务在大陆地区使用并不稳定,因此国行 Android 手机通常会把 Google 服务换成自己的服务,定制一套推送标准。而这对开发者来说,无疑是增大了适配负担。所以针对 Android 端,我们通常会使用第三方推送服务,比如极光推送、友盟推送等。
虽然这些第三方推送服务使用自建的长连接,无法享受操作系统底层的优化,但它们会对所有使用推送服务的 App 共享推送通道,只要有一个使用第三方推送服务的应用没被系统杀死,就可以让消息及时送达。
而另一方面,这些第三方服务简化了业务服务器与手机推送服务建立连接的操作,使得我们的业务服务器通过简单的 API 调用就可以完成消息推送。
而为了保持 Android/iOS 方案的统一,在 iOS 上我们也会使用封装了 APNs 通信的第三方推送服务。
第三方推送的服务流程,如下图所示。
图 2 第三方推送服务流程
这些第三方推送服务厂商提供的能力和接入流程大都一致,考虑到极光的社区和生态相对活跃,所以今天我们就以极光为例,来看看在 Flutter 应用中如何才能引用原生的推送能力。
原生推送接入流程
要想在 Flutter 中接收推送消息,我们需要把原生的推送能力暴露给 Flutter 应用,即在原生代码宿主实现推送能力(极光 SDK)的接入,并通过方法通道提供给 Dart 层感知推送消息的机制。
插件工程
在第 26 篇文章中,我们学习了如何在原生工程中的 Flutter 应用入口注册原生代码宿主回调,从而实现 Dart 层调用原生接口的方案。这种方案简单直接,适用于 Dart 层与原生接口之间交互代码量少、数据流动清晰的场景。 但对于推送这种涉及 Dart 与原生多方数据流转、代码量大的模块,这种与工程耦合的方案就不利于独立开发维护了。这时,我们需要使用 Flutter 提供的插件工程对其进行单独封装。
Flutter 的插件工程与普通的应用工程类似,都有 android 和 ios 目录,这也是我们完成平台相关逻辑代码的地方,而 Flutter 工程插件的注册,则仍会在应用的入口完成。除此之外,插件工程还内嵌了一个 example 工程,这是一个引用了插件代码的普通 Flutter 应用工程。我们通过 example 工程,可以直接调试插件功能。
图 3 插件工程目录结构
在了解了整体工程的目录结构之后,接下来我们需要去 Dart 插件代码所在的 flutter_push_plugin.dart 文件,实现 Dart 层的推送接口封装。
Dart 接口实现
为了实现消息的准确触达,我们需要提供一个可以标识手机上 App 的地址,即 token 或 id。一旦完成地址的上报,我们就可以等待业务服务器给我们发消息了。
因为我们需要使用极光这样的第三方推送服务,所以还得进行一些前置的应用信息关联绑定,以及 SDK 的初始化工作。可以看到,对于一个应用而言,接入推送的过程可以拆解为以下三步:
这三步对应着在 Dart 层需要封装的 3 个原生接口调用:setup、registrationID 和 setOpenNotificationHandler。
前两个接口是在方法通道上调用原生代码宿主提供的方法,而注册消息通知的回调函数 setOpenNotificationHandler 则相反,是原生代码宿主在方法通道上调用 Dart 层所提供的事件回调,因此我们需要在方法通道上为原生代码宿主注册反向回调方法,让原生代码宿主收到消息后可以直接通知它。
另外,考虑到推送是整个应用共享的能力,因此我们将 FlutterPushPlugin 这个类封装成了单例:
class FlutterPushPlugin {
static final FlutterPushPlugin _instance = new FlutterPushPlugin.private(const MethodChannel('flutter_push_plugin'));
final MethodChannel _channel;
EventHandler _onOpenNotification;
FlutterPushPlugin.private(MethodChannel channel) : _channel = channel {
_channel.setMethodCallHandler(_handleMethod);
}
setupWithAppID(String appID) {
_channel.invokeMethod("setup", appID);
}
setOpenNotificationHandler(EventHandler onOpenNotification) {
_onOpenNotification = onOpenNotification;
}
Future<Null> _handleMethod(MethodCall call) {
switch (call.method) {
case "onOpenNotification":
return _onOpenNotification(call.arguments);
default:
throw new UnsupportedError("Unrecognized Event");
}
}
Future<String> get registrationID async {
final String regID = await _channel.invokeMethod('getRegistrationID');
return regID;
}
}
Dart 层是原生代码宿主的代理,可以看到这一层的接口设计算是简单。接下来,我们分别去接管推送的 Android 和 iOS 平台上完成相应的实现。
Android 接口实现
考虑到 Android 平台的推送配置工作相对较少,因此我们先用 Android Studio 打开 example 下的 android 工程进行插件开发工作。需要注意的是,由于 android 子工程的运行依赖于 Flutter 工程编译构建产物,所以在打开 android 工程进行开发前,你需要确保整个工程代码至少 build 过一次,否则 IDE 会报错。
首先,我们需要在插件工程下的 build.gradle 引入极光 SDK,即 jpush 与 jcore:
dependencies {
implementation 'cn.jiguang.sdk:jpush:3.3.4'
implementation 'cn.jiguang.sdk:jcore:2.1.2'
}
然后,在原生接口 FlutterPushPlugin 类中,依次把 Dart 层封装的 3 个接口调用,即 setup、getRegistrationID 与 onOpenNotification,提供极光 Android SDK 的实现版本。
需要注意的是,由于极光 Android SDK 的信息绑定是在应用的打包配置里设置,并不需要通过代码完成(iOS 才需要),因此 setup 方法的 Android 版本是一个空实现:
public class FlutterPushPlugin implements MethodCallHandler {
public final Registrar registrar;
private final MethodChannel channel;
public static FlutterPushPlugin instance;
public static void registerWith(Registrar registrar) {
final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_push_plugin");
instance = new FlutterPushPlugin(registrar, channel);
channel.setMethodCallHandler(instance);
JPushInterface.setDebugMode(true);
JPushInterface.init(registrar.activity().getApplicationContext());
}
private FlutterPushPlugin(Registrar registrar, MethodChannel channel) {
this.registrar = registrar;
this.channel = channel;
}
@Override
public void onMethodCall(MethodCall call, Result result) {
if (call.method.equals("setup")) {
result.success(0);
}
else if (call.method.equals("getRegistrationID")) {
result.success(JPushInterface.getRegistrationID(registrar.context()));
} else {
result.notImplemented();
}
}
public void callbackNotificationOpened(NotificationMessage message) {
channel.invokeMethod("onOpenNotification",message.notificationContent);
}
}
可以看到,我们的 FlutterPushPlugin 类中,仅提供了 callbackNotificationOpened 这个工具方法,用于推送消息参数回调给 Dart,但这个类本身并没有去监听极光 SDK 的推送消息。
为了获取推送消息,我们分别需要继承极光 SDK 提供的两个类:JCommonService 和 JPushMessageReceiver。
JCommonService 是一个后台 Service,实际上是极光共享长连通道的核心,可以在多手机平台上使得推送通道更稳定。
JPushMessageReceiver 则是一个 BroadcastReceiver,推送消息的获取都是通过它实现的。我们可以通过覆盖其 onNotifyMessageOpened 方法,从而在用户点击系统推送消息时获取到通知。
作为 BroadcastReceiver 的 JPushMessageReceiver,可以长期在后台存活,监听远端推送消息,但 Flutter 可就不行了,操作系统会随时释放掉后台应用所占用的资源。因此,在用户点击推送时,我们在收到相应的消息回调后,需要做的第一件事情不是立刻通知 Flutter,而是应该启动应用的 MainActivity。在确保 Flutter 已经完全初始化后,才能通知 Flutter 有新的推送消息。
因此在下面的代码中,我们在打开 MainActivity 后,等待了 1 秒,才执行相应的 Flutter 回调通知:
public class JPushXCustomService extends JCommonService {
}
public class JPushXMessageReceiver extends JPushMessageReceiver {
@Override
public void onNotifyMessageOpened(Context context, final NotificationMessage message) {
try {
String mainClassName = context.getApplicationContext().getPackageName() + ".MainActivity";
Intent i = new Intent(context, Class.forName(mainClassName));
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(i);
} catch (Exception e) {
Log.e("tag","找不到MainActivity");
}
new Timer().schedule(new TimerTask() {
@Override
public void run() {
FlutterPushPlugin.instance.callbackNotificationOpened(message);
}
},1000);
}
}
最后,我们还需要在插件工程的 AndroidManifest.xml 中,分别声明 receiver JPushXMessageReceiver 和 service JPushXCustomService,完成对系统的注册:
...
<application>
<receiver android:name=".JPushXMessageReceiver">
<intent-filter>
<action android:name="cn.jpush.android.intent.RECEIVE_MESSAGE" />
<category android:name="${applicationId}" />
</intent-filter>
</receiver>
<service android:name=".JPushXCustomService"
android:enabled="true"
android:exported="false"
android:process=":pushcore">
<intent-filter>
<action android:name="cn.jiguang.user.service.action" />
</intent-filter>
</service>
</application>
...
接收消息和回调消息的功能完成后,FlutterPushPlugin 插件的 Android 部分就搞定了。接下来,我们去开发插件的 iOS 部分。
iOS 接口实现
与 Android 类似,我们需要使用 Xcode 打开 example 下的 ios 工程进行插件开发工作。同样,在打开 ios 工程前,你需要确保整个工程代码至少 build 过一次,否则 IDE 会报错。
首先,我们需要在插件工程下的 flutter_push_plugin.podspec 文件中引入极光 SDK,即 jpush。这里,我们选用了不使用广告 id 的版本:
Pod::Spec.new do |s|
...
s.dependency 'JPush', '3.2.2-noidfa'
end
然后,在原生接口 FlutterPushPlugin 类中,同样依次为 setup、getRegistrationID 与 onOpenNotification,提供极光 iOS SDK 的实现版本。
需要注意的是,APNs 的推送消息是在 ApplicationDelegate 中回调的,所以我们需要在注册插件时,为插件提供同名的回调函数,让极光 SDK 把推送消息转发到插件的回调函数中。
与 Android 类似,在极光 SDK 收到推送消息时,我们的应用可能处于后台,因此在用户点击了推送消息,把 Flutter 应用唤醒时,我们应该在确保 Flutter 已经完全初始化后,才能通知 Flutter 有新的推送消息。
因此在下面的代码中,我们在用户点击了推送消息后也等待了 1 秒,才执行相应的 Flutter 回调通知:
@implementation FlutterPushPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"flutter_push_plugin" binaryMessenger:[registrar messenger]];
FlutterPushPlugin* instance = [[FlutterPushPlugin alloc] init];
instance.channel = channel;
[registrar addApplicationDelegate:instance];
[registrar addMethodCallDelegate:instance channel:channel];
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if([@"setup" isEqualToString:call.method]) {
[JPUSHService setupWithOption:self.launchOptions appKey:call.arguments channel:@"App Store" apsForProduction:YES advertisingIdentifier:nil];
} else if ([@"getRegistrationID" isEqualToString:call.method]) {
[JPUSHService registrationIDCompletionHandler:^(int resCode, NSString *registrationID) {
result(registrationID);
}];
} else {
result(FlutterMethodNotImplemented);
}
}
-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
JPUSHRegisterEntity * entity = [[JPUSHRegisterEntity alloc] init];
entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound;
[JPUSHService registerForRemoteNotificationConfig:entity delegate:self];
self.launchOptions = launchOptions;
return YES;
}
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
[JPUSHService registerDeviceToken:deviceToken];
}
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler {
NSDictionary * userInfo = response.notification.request.content.userInfo;
NSString *content = userInfo[@"aps"][@"alert"];
if ([content isKindOfClass:[NSDictionary class]]) {
content = userInfo[@"aps"][@"alert"][@"body"];
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.channel invokeMethod:@"onOpenNotification" arguments:content];
});
UIApplication.sharedApplication.applicationIconBadgeNumber = 0;
completionHandler();
}
- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger options))completionHandler {
completionHandler(UNNotificationPresentationOptionAlert);
}
@end
至此,在完成了极光 iOS SDK 的接口封装之后,FlutterPushPlugin 插件的 iOS 部分也搞定了。
FlutterPushPlugin 插件为 Flutter 应用提供了原生推送的封装,不过要想 example 工程能够真正地接收到推送消息,我们还需要对 exmaple 工程进行最后的配置,即:为它提供应用推送证书,并关联极光应用配置。
应用工程配置
在单独为 Android/iOS 应用进行推送配置之前,我们首先需要去极光的官方网站,为 example 应用注册一个唯一标识符(即 AppKey): 图 4 极光应用注册
在得到了 AppKey 之后,我们需要依次进行 Android 与 iOS 的配置工作。
Android 的配置工作相对简单,整个配置过程完全是应用与极光 SDK 的关联工作。
首先,根据 example 的 Android 工程包名,完成 Android 工程的推送注册:
图 5 example Android 推送注册
然后,通过 AppKey,在 app 的 build.gradle 文件中实现极光信息的绑定:
defaultConfig {
...
ndk {
abiFilters 'armeabi', 'armeabi-v7a', 'arm64-v8a'
}
manifestPlaceholders = [
JPUSH_PKGNAME : applicationId,
JPUSH_APPKEY : "f861910af12a509b34e266c2",
JPUSH_CHANNEL : "developer-default",
]
}
至此,Android 部分的所有配置工作和接口实现都已经搞定了。接下来,我们再来看看 iOS 的配置实现。
iOS 的应用配置相对 Android 会繁琐一些,因为整个配置过程涉及应用、苹果 APNs 服务、极光三方之间的信息关联。
除了需要在应用内绑定极光信息之外(即 handleMethodCall 中的 setup 方法),还需要在苹果的开发者官网提前申请苹果的推送证书。关于申请证书,苹果提供了.p12 证书和 APNs Auth Key 两种鉴权方式。 这里,我推荐使用更为简单的 Auth Key 方式。申请推送证书的过程,极光官网提供了详细的注册步骤,这里我就不再赘述了。需要注意的是,申请 iOS 的推送证书时,你只能使用付费的苹果开发者账号。 在拿到了 APNs Auth Key 之后,我们同样需要去极光官网,根据 Bundle ID 进行推送设置,并把 Auth Key 上传至极光进行托管,由它完成与苹果的鉴权工作:
图 6 example iOS 推送注册
通过上面的步骤,我们已经完成了将推送证书与极光信息绑定的操作,接下来,我们回到 Xcode 打开的 example 工程,进行最后的配置工作。
首先,我们需要为 example 工程开启 Application Target 的 Capabilities->Push Notifications 选项,启动应用的推送能力支持,如下图所示:
图 7 example iOS 推送配置
然后,我们需要切换到 Application Target 的 Info 面板,手动配置 NSAppTransportSecurity 键值对,以支持极光 SDK 非 https 域名服务:
图 8 example iOS 支持 Http 配置
最后,在 Info tab 下的 Bundle identifier 项,把我们刚刚在极光官网注册的 Bundle ID 显式地更新进去:
图 9 Bundle ID 配置
至此,example 工程运行所需的所有原生配置工作和接口实现都已经搞定了。接下来,我们就可以在 example 工程中的 main.dart 文件中,使用 FlutterPushPlugin 插件来实现原生推送能力了。
在下面的代码中,我们在 main 函数的入口,使用插件单例注册了极光推送服务,随后在应用 State 初始化时,获取了极光推送地址,并设置了消息推送回调:
FlutterPushPlugin fpush = FlutterPushPlugin();
void main() {
fpush.setupWithAppID("f861910af12a509b34e266c2");
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
String _regID = 'Unknown';
String _notification = "";
@override
initState() {
super.initState();
fpush.setOpenNotificationHandler((String message) async {
setState(() {
_notification = message;
});
});
initPlatformState();
}
initPlatformState() async {
String regID = await fpush.registrationID;
setState(() {
_regID = regID;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
children: <Widget>[
Text('Running on: $_regID\n'),
Text('Notification Received: $_notification')
],
),
),
),
);
}
}
点击运行,可以看到,我们的应用已经可以获取到极光推送地址了:
图 10 iOS 运行示例
图 11 Android 运行示例
接下来,我们再去极光开发者服务后台发一条真实的推送消息。在服务后台选择我们的 App,随后进入极光推送控制台。这时,我们就可以进行消息推送测试了。 在发送通知一栏,我们把通知标题改为“测试”,通知内容设置为“极光推送测试”;在目标人群一栏,由于是测试账号,我们可以直接选择“广播所有人”,如果你希望精确定位到接收方,也可以提供在应用中获取到的极光推送地址(即 Registration ID):
图 12 极光推送后台
点击发送预览并确认,可以看到,我们的应用不仅可以被来自极光的推送消息唤醒,还可以在 Flutter 应用内收到来自原生宿主转发的消息内容: