适配AndroidQ,不能后台启动Activity限制
在AndroidQ或例如Vivo、小米等第三方厂商ROM中,都对后台启动Activity做了限制,AndroidQ中并没有设计有权限申请来进行设置,而Vivo、小米则是在App权限设置中加入了后台启动Activity的权限。
Vivo拦截应用后台启动Activity通知.png Vivo后台弹出界面权限开关.png MIUI10后台弹出界面权限开关.png而默认该项权限都是关闭的,并且因为没有Api可以调用申请。当App第一次在后台情况下跳转Activity时,系统会进行拦截,并弹出一条通知告诉用户,后续则不会重复提醒,而原生AndroidQ则是不提醒,直接拦截。
为什么要后台时跳转,场景呢
- 场景一:启动页,展示品牌Logo和广告,5秒后跳转
有些小伙伴会问了,什么情况我们会后台进行跳转Activity呢?用户都不在操作界面,怎么会有跳转操作呢?其实有一种情况就足以解释了。
例如我们App一般都会设计一个SplashActivity或者一个WelcomeActivity来作为启动页,显示品牌Logo或者广告(一般都有),显示页面5秒后跳转到主页,只要设置用户点击了Home键回到桌面或者点击了其他App的通知(例如微信、QQ等),就会跳转到通知来源的App,这是App还在计时,当过了5秒后,就会执行跳转操作,这时例如突然收到老板紧急微信,马上点击通知,去到微信中打字,就会突然被这个跳转的App拉回去,造成很不好的体验。(如果是我肯定很烦躁!)
相比iOS,跳转ViewController则不会拉回App。重新点击App时是已经跳转好的了。
- 场景二:电脑版登录,弹出界面确定登录
这种情况在多端登录时,可能会遇到,例如:在电脑端微信,请求登录微信,这时手机微信就会弹出一个Activity让我们点击确认,电脑端再进行登录。这时刚好撞到枪口上了,如果在AndroidQ中,界面就跳转失败了。
找出厂商后台打开Activity的权限开关(不推荐)
和以前权限被关闭,让用户去设置中打开权限类似,例如用获取当前Activity应用,找出厂商的打开后台Activity的权限页面,其实这种方式工作量是最大的,要适配各大厂商的ROM,而且ROM又有不同的版本(例如MIUI9、MIUI10等),而且权限申请还有个理由请求获取,这个后台打开Activity的权限理由,也不好编,让用户觉得你要获取后台弹出的权限,十有八九都觉得你会干坏事,也自然不会允许了。
解决方案,怎么适配
-
启动页延时5秒后跳转的场景
一般Android端中进行定时、延时功能,一种是使用Timer定时器,一种是使用Handler循环调用来实现。
- 对于Timer,我们可以在启动页的onPause()中,将Timer停止,计算剩余时间,并在下一次的onResume()中重新启动一个Timer继续计时。
- 对于Handler,和上面Timer的处理方式一样,onPause()中removeCallbacksAndMessages(null)来去掉跳转的Message()或Runnable,再在下一次的onResume()中继续sendMessage()或postDelayed()继续计时。
- 之前有小伙伴提出过一种粗暴的手法,在跳转前,开启一个定时器,预估个2秒左右,判断当前Activity是否是本次跳转的Activity来判定跳转是否被拦截。对于该方案,我不是很推荐,因为预估的时间怎么评定比较不靠谱,不是每个页面跳转都是2秒,例如像淘宝、支付宝这种量级比较大的App,他们的跳转未必有那么快,尤其是冷启动的时候,往往会比较卡、比较久,低配置的机子会更加明显。
-
微信电脑端登录的场景
其实对于微信的自动跳转,QQ则是更加优雅的方式,QQ的方案是发出一个通知,用户点击通知,回到QQ进行确认授权登录,而在跳转确定页面中,一种是在通知中,我们可以给PendingIntent设置跳转去Activity来进行跳转,而QQ则应该是考虑到了以下场景:
- 如果用户手滑,划走了通知,跳转确定需要再次在电脑端申请,再弹一次。
- 用户没有点击通知,而是直接点开QQ,却没有确定信息可以直接确认,因为需要点击通知才能跳转。
那么QQ的做法是什么呢?我预估就是发起通知的同时,再栈顶的Activity跳转一个Fragment,或者使用一个View、Layout进行覆盖来显示。而通知的操作则是将应用的Activity栈拉回前台。
官方适配方案
Google推荐方式是,类似QQ的友好提示,在应用在后台时,应该发起一个通知,用户点击后跳转。
- 如果需要考虑到上面QQ考虑到的情况,发送通知,让用户点击通知将应用拉回前台,栈顶Activity跳转一个Fragment。
但是上面这种方式也有缺点:
-
跳转Framgnet必须依赖Activity,如果应用被杀死了,所有Activity都回销毁回收,用户再点击通知时,拉起QQ后,再在最前的Activity进行跳转Fragment。对于一般流程,我们是先启动一个启动页再跳转到首页,很有可能出现在启动页中跳转了Fragment,然后在5秒后跳转到首页,恰巧跳转过去同时,启动页被销毁,这个Fragment顺带被销毁了。
-
一般的路由框架,例如ARouter,Fragment跳转并不支持拦截,例如登录确认的页面,需要跳转前判断是否登录,未登录跳转到登录页面。当然一般不会,除非出现了Bug,就会可能在未登录时收到了确认登录的通知。例如某些极端情况,在电脑端进行申请登录,手机端刚好选择退出登录,如果推送消息或长连接稍微因为网络卡了一点,在退出登录之后收到了,就可能跳转去了确认的页面(我司的测试小姐姐、小哥哥们就是这么变态啊~他们真的会这么做的!)。作为严谨的程序员,我们还是需要加上登录判断,如果是旧页面就已经使用路由拦截器来验证,修改为Fragment就不能使用该功能了。
-
如果是新模块这么写固然可以,但是如果是旧模块,已经是用跳转Activity的方式写了,改为Fragment会有些不现实,例如该界面有startActivity()的操作,耦合到了Activity的onActivityResult(),需要将原本的onActivityResult()挪到依附的Activity,改动太大了,并且依附到哪个Activity都是不确定的(只是栈顶就可以),代码放哪里都不合适,当时你可以将onActivityResult()的调用分发给所有依附的Fragment来解决,需要改动原有代码,总体来说不合适,我们需要通用方案,遵循开闭原则,对修改关闭,对拓展开放。
解决方案
既然跳转Fragment依赖Activity,造成了耦合,我来讲一下我的解决方案吧~
首先跳转Activity会强行拉起我们的App,对用户造成了不好的影响,那么我们可以在跳转前,判断应用处于前台还是后台。
- 前台:直接跳转即可。
- 后台:则等到下一次用户回到App时继续跳转,与此同时,发送一个通知,让用户点击后将App拉回前台(并不是所有的跳转操作都需要发送通知,类似微信登录确认这种强调马上确认的场景才进行发送,不然用户频繁在跳转前将App后台了,就会频繁发出通知,一般来讲用户不会这样做,但是测试小姐姐、小哥哥会酱紫做呀!(大部分是我自己强迫症的原因))。
对于前后台判断和监控有很多种方式,这里推荐我之前写的一篇文章Android App前后台监控,对于前后台判断、以及前、后台监听,都有现成的Api使用。
实现步骤
- 统一在跳转前判断前、后台情况。
- 在前台直接跳转,再后台则注册一个回到前台时的监听回调,回调时再进行跳转。
- 发送通知,用户点击通知,将App拉回前台,就会触发步骤二时注册的监听,从而继续跳转。
这样子的好处是,一是并没有强制改动为Fragment导致代码大改,而是在后台时暂停跳转,回到前台时继续跳转。
示例代码
- 例如跳转到订单详情,我使用ARouter来跳转,再用kotlin来对ARouter的navigation()跳转方法做了一个拓展方法来统一跳转。
/**
* 跳转到订单详情
*/
fun goWorkOrderDetail(
activity: Activity,
workOrderId: String,
source: RouteSource = RouteSource.NORMAL,
callback: NavigationCallback? = null
) {
ARouter.getInstance()
.build(ARouterUrl.WORK_ORDER_DETAIL)
//传递订单Id
.withString(WorkConstant.Args.WORK_ORDER_ID, workOrderId)
.startNavigation(activity, source = source, callback = callback)
}
-
RouteSource枚举,代表跳转时的来源,暂时定义了3种来源
- NORMAL,普通跳转,正常的Activity应用内跳转,会进行前、后台判断。
- NOTIFICATION,点击通知进行的跳转,不会判断前、后台判断。(不然,在后台时发送给用户的通知被点击了,又会因为在后台被拦截掉)
- SHORTCUT,8.0的Shortcut快捷方式来进行跳转,也不行前、后台判断。
enum class RouteSource(val code: Int) {
/**
* 普通跳转
*/
NORMAL(1),
/**
* 通知栏跳转
*/
NOTIFICATION(2),
/**
* 桌面快捷方式
*/
SHORTCUT(3)
}
-
startNavigation()拓展方法。
- continueNavigation(),正在发起跳转的方法,kotlin的方法支持内嵌方法,如果大家用Java来写,则可以将方法中的代码封装为Runnable即可。navigation()方法中,我统一包裹了一个NavigationCallback回调来包裹用户传入的跳转回调监听callback,目的是为了统一处理拦截的情况。
- 我使用AppMonitor.isAppBackground()方法判断当前是否在后台,并且传入的跳转来源RouteSource枚举是否为普通跳转,是则使用AppMonitor.register()方法来注册一个回到前台时的回调。被回调时,再调用continueNavigation()方法来继续完成跳转,记得取消注册,避免下一次切换又被回调。
- sendMoveAppForegroundMsg(),发送通知,大家按自己项目封装的来即可,由于我封装得比较多,不是本篇重点,所以只贴出通知被点击时调用的moveAppToForeground()方法。
- navNotification,是提供给发送通知时使用的标题和内容。
- 走到else分支,则代表是在前台,那么我们直接调用continueNavigation(),正常跳转即可。
/**
* ARouter跳转Activity统一拓展
* @param requestCode startActivityForResult时使用的requestCode
* @param callback 跳转回调
* @param source 跳转来源类型,默认为普通跳转,会进行后台、前台判断,如果为后台则到下一次回到前台时再跳转
* @param navNotification 当跳转时不在前台时,是否发送一条通知让用户跳转回App,Pair的2个参数分别为title和content
* 这个主要是为了兼容AndroidQ的各大第三方厂商定制的不让后台时跳转Activity的权限,一般用于像电脑登录微信时弹出界面使用
*/
@JvmOverloads
fun Postcard.startNavigation(
activity: Activity,
requestCode: Int = -1,
callback: NavigationCallback? = null,
source: RouteSource = RouteSource.NORMAL,
navNotification: Pair<String, String>? = null
) {
//这里兼容AndroidQ的限制,App不在后台进行跳转的情况
AppMonitor.get().run {
//正在发起跳转的方法
fun continueNavigation() {
navigation(activity, requestCode, object : NavigationCallback {
override fun onFound(postcard: Postcard?) {
//路由目标被发现时调用
callback?.onFound(postcard)
}
override fun onArrival(postcard: Postcard?) {
//路由到达时调用
callback?.onArrival(postcard)
}
override fun onLost(postcard: Postcard?) {
//路由被丢失时调用
callback?.onLost(postcard)
}
override fun onInterrupt(postcard: Postcard?) {
//路由被拦截时调用,统一拦截处理,这里我贴一下我写的登录统一拦截处理
//未登录,拦截了,由于登录拦截器中,拦截时会给跳转参数中加一个标志位,如果判断到有标志位,就代表被拦截了,则跳转到登录页面
postcard?.run {
if (extras.getBoolean(ARouterUrl.IS_LOGIN_INTERCEPTOR)) {
ARouter.getInstance()
.build(ARouterUrl.LOGIN_LOGIN)
.navigation(activity)
}
}
callback?.onInterrupt(postcard)
}
})
}
//不在前台,订阅一个切换回前台的回调,回到前台时,再继续跳转
if (isAppBackground && RouteSource.NORMAL == source) {
register(object : AppMonitor.CallbackAdapter() {
override fun onAppForeground() {
super.onAppForeground()
//回调一次后就取消注册,否则会重复回调
unRegister(this)
//回到前台再继续跳转
continueNavigation()
}
})
//发送一条通知提醒用户点击,用户点击后再跳转
if (navNotification != null) {
sendMoveAppForegroundMsg()
}
} else {
//在前台或者通知栏跳转,直接跳转
continueNavigation()
}
}
}
- 通知被点击后,执行moveAppToForeground,将App从后台拉回到前台
/**
* 将栈顶activity移到前台
*/
public void moveAppToForeground(Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningTaskInfo> tasks = activityManager.getRunningTasks(100);
for (ActivityManager.RunningTaskInfo task : tasks) {
if (task.topActivity.getPackageName().equals(context.getPackageName())) {
activityManager.moveTaskToFront(task.id, ActivityManager.MOVE_TASK_WITH_HOME);
}
}
}
没有封装统一跳转,无侵入式拦截
如果没有封装类似startNavigation(),统一的跳转方法,而使用了路由框架。例如ARouter,我们可以建立一个拦截器。在拦截器中进行前、后台判断。
- 在拦截器中判断前、后台情况,后台时,注册前台回调,记得取消注册,避免下一次切换又被回调,发送通知。下次回到前台时继续跳转。
- 在前台,继续跳转。
@Interceptor(priority = 1)
class AppNavgationInterceptor : IInterceptor {
override fun init(context: Context?) {
}
override fun process(postcard: Postcard?, callback: InterceptorCallback?) {
AppMonitor.get().run {
//在后台,拦截
if (isAppBackground) {
//获取RouteSource
val routeSource = postcard?.extras?.getSerializable("route_source") as? RouteSource
//获取要发送通知标题、内容
val navNotificationPair = postcard?.extras?.getSerializable("nav_notification") as? Pair<String, String>
if(routeSource == RouteSource.NORMAL) {
register(object :AppMonitor.CallbackAdapter() {
override fun onAppForeground() {
super.onAppForeground()
unRegister(this)
//回到前台后,继续跳转
callback?.onContinue(postcard)
}
})
//发送一条通知提醒用户点击,用户点击后再跳转
if (navNotificationPair != null) {
sendMoveAppForegroundMsg()
}
} else {
//不是普通跳转类型,不进行拦截
callback?.onContinue(postcard)
}
} else {
//在前台,放行,继续跳转
callback?.onContinue(postcard)
}
}
}
}
- startNavigation()方法,则需要修改一下,主要是将跳转来源和通知标题、内容放到跳转参数,让拦截器获取。直接使用navigation()跳转即可。
@JvmOverloads
fun Postcard.startNavigation(
activity: Activity,
requestCode: Int = -1,
callback: NavigationCallback? = null,
source: RouteSource = RouteSource.NORMAL,
navNotification: Pair<String, String>? = null
) {
//修改1:将跳转来源和通知标题、内容放到跳转参数,让拦截器获取
withSerializable("route_source", source)
if (navNotification != null) {
withSerializable("nav_notification", navNotification)
}
navigation(activity, requestCode, object : NavigationCallback {
//...省略其他复写方法
override fun onInterrupt(postcard: Postcard?) {
//省略登录拦截处理
callback?.onInterrupt(postcard)
}
})
}