Flutter路由的SingleTop启动模式 & 冷热启动指定页面的实现
前言
本文涉及到的内容有点多,我们先从 Flutter 路由的 SingleTop 的实现说起,如果对 Flutter 路由的启动模式定义有疑问,可以先看看前文【传送门】,本文的实现也是基于前文的效果更进一步实现。
先说一句抱歉,前文的开头举例并不应景,其实应该放在本文来讲。前文的效果应该是对应文章尾部的效果:首页,登录,注册,这样页面之间的跳转用到 SingleTask 启动模式,而通过通知栏的跳转到页面应该是本文 SingleTop 启动模式来解释的话更合适一点。
本来是想分两篇文章来讲,但是我想到这两个点有点强关联,为了大家能更好理解就一起讲了。
这里就还是以点击通知栏跳转到指定页面为例,默认的 SingleTop 模式肯定是能实现的,SingleTask 其实也能实现。主要是看大家想实现什么样的效果,一个是检查栈顶,一个是检查全栈。各有各自的使用场景。
那么我们就能带着疑问点往下走?
-
跳转到指定的页页面,SingleTop 是怎么实现的?和 SingleTask 有什么区别?
-
如果我现在收到推送了,然后我把 App 杀死了!此时再点击推送消息栏,想跳转到指定页面,此时阁下又该如何应对?
-
如何判断应用是否已启动?
-
Flutter中常用的 ChannelUrlLaunch 相关用法如何结合实现封装?
好吧,接下来就让我们带着疑问往下看吧。
一、如何实现一个 SingleTop 的模式
在前文 SingleTask 的启动模式实现过程中,我们了解到我们可以通过监听路由的几种创建方式与路由的销毁方式,我们可以实现自己的路由表。然后通过自己的路由表就能判断页面是否存在,从而实现前进跳转还是后退跳转的方式从而实现 SingleTask 的逻辑。
既然我们已经有了自己的路由表,那么 SingleTop 的实现貌似并不太难,之前我们是检查全栈是否存在目标路由,而此时此景我们只需要检查栈顶是否存在目标路由即可。
只是实现跳转的逻辑与前者有一点点不同,当栈顶存在目标路由的时候,我们其实不需要处理或者使用 until 的方式也行,只是没什么效果相当于不处理,而当栈顶不存在目标路由的时候我们是直接跳转的。
修改之后的代码如下:
arduino
class MyRouterHistoryManager {
static final MyRouterHistoryManager _instance = MyRouterHistoryManager._internal();
factory MyRouterHistoryManager() {
return _instance;
}
MyRouterHistoryManager._internal();
final List<String?> _routeNames = [];
...
// 只是检查栈顶
bool isTopRouteExist(String routeName) {
if (_routeNames.isNotEmpty) {
return _routeNames.first == routeName;
} else {
return false;
}
}
}
并且我们加入类似 SingleTask 扩展方法
javascript
extension GetRouterNavigation on GetInterface {
...
/// 查询指定的RouterName是否存在自己的路由栈顶
bool isTopRouteExist(String routerName) {
return MyRouterHistoryManager().isTopRouteExist(routerName);
}
/// 跳转页面SingleTop模式
void toNamedSingleTop(
String routerName, {
dynamic arguments,
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) {
if (isTopRouteExist(routerName)) {
Get.until((route) => route.settings.name == routerName);
} else {
Get.toNamed(routerName, arguments: arguments, parameters: parameters)?.then((value) => {
if (cb != null) {cb(value)}
});
}
}
}
我们测试一下 SingleTask 与 SingleTop 的区别:
加入我们从登录页面跳转到注册页面,然后用 SingleTask 与 SingleTop 启动到登录页面。
ini
// Get.toNamedSingleTask(RouterPath.AUTH_LOGIN);
Get.toNamedSingleTop(RouterPath.AUTH_LOGIN);
两者的区别是 SingleTask 启动,注册页面会返回到登录页面,而 toNamedSingleTop 会重新启动一个新的登录页面。
而在登录页面调用 SingleTop 启动到登录页面,则会没有效果,到此就符合我们的预期了。
二、如何跳转到App指定的路由
那么我们接下来看下面的问题,我们的应用不只是在应用内跳转,我们还有一些场景是要跳转到指定的路由下。
如果应用已经是启动状态,我们可以通过 SingleTask 或 SingleTop 的方式启动指定的页面,而如果我们的应用没启动呢?
例如开头的场景,我们收到推送之后,点击推送的消息通知进入到消息页面。此时如果应用是启动状态,那么会容易跳转过去,但是如果此时我们把应用杀死,再次点击消息通知,它只会默认的打开我们App而不会跳转到消息页面!
怎会如此?因为 Flutter 的路由跳转与原生 Android 平台的 Activity 是两回事。
其实可以这么理解,以原生 Android 平台为例,我们可以理解为 Flutter 的 App 是一个单 Activity 多 Fragemt 架构,Android 的配置只是提供了一个 MainActivity ,内部的跳转是由 Flutter 的路由切换的。
那我们应该怎么做?
具体思路如下:
- 我们定义一个自己的 Activity ,使用 url_launcher 框架携带需要目标页面的路由参数。
- 在原生平台中,通过解析 Uri 获取到路由参数并保存起来。
- 在 Flutter 平台通过 MethodChannel 获取到储存的路由Name。
- 在冷启动的时候初始化完成进入Splash页面进行判断,取出对应的目标路由值,并及时清除。
当然原生 Android 与 iOS 平台的实现不同,步骤有差异。因为 Android 平台只有一个 MainActivity,且定义为 android.intent.category.LAUNCHER ,我们无法为它指定自定义的 Scheme 与 host 。所以在 Android 平台我们需要自定义一个1像素的 Activity 用于接收与存储目标对象:
原生 Android 的自定义 Activity 如下:
ini
public class RouterActivity extends Activity {
public static String ROUTER_NAME = "";
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 设置Activity为1像素大小及透明背景
WindowManager.LayoutParams params = getWindow().getAttributes();
params.height = 1;
params.width = 1;
params.alpha = 0;
getWindow().setAttributes(params);
// 接收传递的参数
Bundle extras = getIntent().getExtras();
if (extras != null && extras.containsKey("name")) {
String name = extras.getString("name");
Log.w("RouterActivity","RouterActivity Bundle拿到路由名称参数为: " + name);
ROUTER_NAME = name;
}
Intent intent = getIntent();
if (intent != null && intent.getData() != null) {
Uri uri = intent.getData();
Log.w("RouterActivity","RouterActivity 拿到 Scheme Uri: " + uri);
String name = uri.getQueryParameter("name");
Log.w("RouterActivity","RouterActivity Uri拿到路由名称参数为: " + name);
ROUTER_NAME = name;
}
// 关闭Activity
finish();
}
}
清单文件注册并设置自定义的 Scheme 与 host ,便于跳转
ini
<activity android:name=".RouterActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="router"
android:scheme="zrjz" />
</intent-filter>
</activity>
在 Flutter 端我们定义 MethodChannel 去获取与清除路由值
csharp
class AppChannel {
static const platform = MethodChannel('zrjz-opapp');
/// 拿到原生平台的路由地址
static Future<String> getNativeRouterName() async {
try {
return await platform.invokeMethod('getNativeRouterName');
} on PlatformException catch (e) {
Log.e(e.message ?? 'getNativeRouterName Channel 错误');
return '';
}
}
/// 清除原生平台的路由地址
static Future<bool> clearNativeRouterName() async {
try {
return await platform.invokeMethod('clearNativeRouterName');
} on PlatformException catch (e) {
Log.e(e.message ?? 'clearNativeRouterName Channel 错误');
return false;
}
}
}
那么在原生 MainActivity 中我们实现对应的 Channel 实现:
less
@Override
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
super.configureFlutterEngine(flutterEngine);
MethodChannel methodChannel = new MethodChannel(flutterEngine.getDartExecutor(), "zrjz-opapp");
methodChannel.setMethodCallHandler((call, result) -> {
if (call.method.equals("getNativeRouterName")) {
// 获取当前需要跳转的子路由
Log.d("MainActivity", "执行 methodChannel -> getNativeRouterName");
result.success(RouterActivity.ROUTER_NAME);
} else if (call.method.equals("clearNativeRouterName")) {
// 清除当前需要跳转的子路由
Log.d("MainActivity", "执行 methodChannel -> clearNativeRouterName");
RouterActivity.ROUTER_NAME = "";
result.success(true);
}else {
result.notImplemented();
}
});
}
那么我们在点击推送的消息通知栏,启动应用的时候,此时就调用 url_launch 的方式启动我们自定义的 Activity:
dart
const url = 'zrjz://router?name=${RouterPath.MAIN_NOTIFICATION}';
if (await canLaunch(url)) {
await launch(url);
} else {
Log.d('无法启动此scheme-host');
}
那么启动应用后当我们自己的应用初始化都完成了,我们就能在 Splash 页面获取到 Channel 中拿到的路由值进行跳转
类似代码:
ini
bool isFirstGuide = SpUtil.getBool(AppConstant.storageGuideFirst) ?? false;
Log.d("SplashController - 查询SP isFirstGuide:$isFirstGuide");
// 先查询原生平台有没有保存需要跳转的子路由
String routerName = await AppChannel.getNativeRouterName();
Log.d('SplashController - 查询原生平台有没有保存需要跳转的子路由:$routerName');
if (!Utils.isEmpty(routerName)) {
// 只跳转一次
Get.offAllNamed(routerName);
AppChannel.clearNativeRouterName();
}else{
// 如果没有经历过GUIDE页面,进入GUIDE,否则进入首页
if (isFirstGuide == true) {
Get.offAllNamed(RouterPath.MAIN);
} else {
Get.offAllNamed(RouterPath.GUIDE);
}
}
此时就不会跳转到主页,而是直接跳转到通知页面,点击返回就是直接退出应用。而如果你的需求是想返回回到首页,那么你就可以在首页做判断。具体场景灵活使用。
三、最终实现与SingleTop的封装
懂了,启动 aunchUrl 并且设置 SingleTop 就可以了! 两个都设置,双重保险?
javascript
void toNamedSingleTop(
String routerName, {
dynamic arguments,
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) async {
//冷启动
String url = 'zrjz://router?name=$routerName';
if (await canLaunch(url)) {
await launch(url);
} else {
Log.d('无法启动此scheme-host');
}
//正常跳转
if (isTopRouteExist(routerName)) {
Get.until((route) => route.settings.name == routerName);
} else {
Get.toNamed(routerName, arguments: arguments, parameters: parameters)?.then((value) => {
if (cb != null) {cb(value)}
});
}
}
尬住了,这。。。有很大的问题啊。
如果此时应用已经启动,你这么跳转确实是能跳转到通知页面。但是同时由于启动了 launchUrl,所以我们之前定义的 RouterActivity 也会启动,也会设置 RouterName ,那么你下次手动的启动此应用,就会直接进入通知页面了。
所以我们还是要区分应用是否已经存在,如果存在我们使用默认的方式跳转即可,如果不存在,我们才使用 RouterActivity 的方式设置 RouterName ,当推送的通知栏配置的 PendingIntent 启动了我们的应用,就能获取到 RouterName ,从而跳转到指定的页面。
由于我们 Flutter App 只有一个 MainActivity,所以我们不能像原生 Android 一样根据不同的类似设置不同的 PendingIntent 跳转到不同的 Activity ,没有办法所以只能用这样间接的方案了。
这个东西不能乱用,只用于通知栏跳转,如果在普通的跳转中使用这种方案,就会导致下一次启动再次跳转到这个页面,所以我们抽取出来专用于通知栏跳转。
如何判断应用是否存活,可以在启动应用的时候在某一个 Service 中定义一个变量判断。也可以通过我们的路由表是否为空来判断。
dart
extension GetRouterNavigation on GetInterface {
...
/// 通知栏点击跳转到指定页面
void toNamedByNotification(
String routerName, {
dynamic arguments,
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) async {
if (MyRouterHistoryManager().routeNames.isEmpty) {
//冷启动
String url = 'zrjz://router?name=$routerName';
if (await canLaunch(url)) {
await launch(url);
} else {
Log.d('无法启动此scheme-host');
}
} else {
//正常跳转
if (isTopRouteExist(routerName)) {
Get.until((route) => route.settings.name == routerName);
} else {
Get.toNamed(routerName, arguments: arguments, parameters: parameters)?.then((value) => {
if (cb != null) {cb(value)}
});
}
}
}
}
以 Jpush 为例:
dart
void init() {
JPush jpush = JPush();
jpush.addEventHandler(
// 接收通知回调方法。
onReceiveNotification: (Map<String, dynamic> message) async {
Log.d("flutter-jpush: onReceiveNotification: $message");
},
// 点击通知回调方法。
onOpenNotification: (Map<String, dynamic> message) async {
Log.d("flutter-jpush: onOpenNotification: $message");
//根据后端返回的type类型跳转到不同的路由页面
if (message.containsKey('type')) {
String type = message['type'];
if (type == 'giro') {
...
} else if (type == 'invoice') {
...
} else {
//默认跳转到通知页面
Get.toNamedByNotification(RouterPath.MAIN_NOTIFICATION);
}
}
},
// 接收自定义消息回调方法。
onReceiveMessage: (Map<String, dynamic> message) async {
Log.d("flutter-jpush: onReceiveMessage: $message");
},
...
}
前台点击通知栏的Log:
后台启动通知栏Log:
总结步骤:
- 自定义 1 像素的 RouterActivity,并设置 scheme host ,并取出对应的参数值。
- 定义双端的 MethodChannel 并获取到 RouterActivity 中保存的路由值。
- 定义 Get 或 Navigatior 的扩展函数,分为已启动和未启动实现跳转逻辑。
- 在启动应用后的 Splash 页面或 Main 页面设置 Channel 路由的跳转。
后记
不好意思我忘记声明了一点,此类方法只是基于 Flutter App 的方式,而不是 Flutter Module 的方式,如果大家使用的原生 App 混入 Flutter Module 这样的场景,那么可能并不是适用这个方法哦,因为实现的方法有很多。
如果是原生 App 为主集成 Flutter 模块的方式,可以使用自定义 Flutter 引擎的方式,可以使用不同的 Activity 加载不同的引擎,也可以在引擎中指定初始路由等其他方式。也就是多引擎,多页面等多种方式实现,自主性比较大,大家可以按需自己选择实现。
由于我们是 Flutter App 的开发方式,不得已才 "胆大妄为" 的搞一些 "歪门邪道" ,硬着头皮简单的实现了一下。
诚惶诚恐!由于本人开发 Flutte 时间并不长,实在不知道有没有更好的方案,如果有其他方案还请评论区大家一起交流哦,如本文讲的错漏的地方,希望同学们可以评论区指出。
本文最终代码在文章末尾,有兴趣的可以复制代码进行测试,都是比较简单的工具代码,是基于 GetX 框架扩展的,如果你没有使用 Get 框架,并没有关系其实也是可用的,只需要简单修改一下即可。
关于后续我也会持续分享一些实际开发中 Flutter 的踩坑与其他实现方案思路,有兴趣可以关注一下。
如果感觉本文对你有一点点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力啦。
Ok,这一期就此完结。