58 同城 Android 端实现外部调起的关键技术解析

背景

外部调起是指外部应用打开目标应用的具体落地页的方式,例如手机上通过浏览器打开58同城app某个落地页,这是一个目前常规的客户端行为,也是启动app的一种方式,通常情况下常规操作的实现较容易,但是要想实现一些特定需求就需要我们使用一些技术方法来实现了,本文分享的内容主要就是58同城实现外部调起特定需求和问题所使用的关键技术,同时对原理进行解析。

外部调起实现新建Task,创建后台任务管理应用

通常情况下,当应用A调起应用B的目标页时,使用Intent启动Demo演示:


查看堆栈信息:adb shell dumpsys activity activities


通过查看当前栈信息可以看到应用B的目标页activity会被压栈入应用A的回退栈中,同时查看后台任务发现只有应用A的任务。


通过以上实验我们不难看出,目标应用B被应用A调起了,并且activity被压入了应用A中,后台任务管理器只能看到应用A,并且后台打开应用A时依然看到的是应用B的界面,退出应用B的界面显示应用A,返回栈逻辑没问题,但是对于应用B而言往往不是期望的,好不容易把应用B调起了,应该像正常启动一样,创建新的Task,有独立的后台任务,那应该如何实现呢?

显式设定FLAG_ACTIVITY _
NEW_TASK的Intent flag和相关技术原理

为实现创建新的Task,有独立的后台任务,我们使用 FLAG_ACTIVITY_NEW_TASK
的intent flag,当应用A调起应用B的目标页时,应用B的目标页activity会被压栈入新的回退栈中,Demo演示:


查看堆栈信息:adb shell dumpsys activity activities


可以看出应用B的目标页activity会被压栈入新的回退栈中。


当返回应用B的activity时,应用B退出,未回退到应用A。

这就是FLAG_ACTIVITY_NEW_TASK的 FLAG的作用,使被启动的目标页脱离启动应用的Task,不被压入启动应用的回退栈中,那么
FLAG_ACTIVITY_NEW_TASK
的工作原理是什么呢?为便于更好理解这个flag讲解一下相关基础知识点。

Task和Back Stack

A task is a collection of activities that users interact with when performing a certain job. The activities are arranged in a stack—the back stack)—in the order in which each activity is opened. For example, an email app might have one activity to show a list of new messages. When the user selects a message, a new activity opens to view that message. This new activity is added to the back stack. If the user presses the Back button, that new activity is finished and popped off the stack.
一个Task是应用用户交互界面activities的集合,这些activities 会按执行顺序被放进Back Stack中,例如一个Email APP 有一个消息列表Activity,点击某条消息启动一个新的消息activity界面,这个消息activity会被压入应用的back stack中,如果用户触发返回按钮,这个新加的activity会销毁并且弹出back stack。

管理Tasks

如何管理Tasks和back stack(后进先出),以达到我们想实现的行为和Tasks呢?官方给出的建议是:
You can do these things and more, with attributes in the manifest element and with flags in the intent that you pass to startActivity(). In this regard, the principal attributes you can use are:

  • taskAffinity
  • launchMode
  • allowTaskReparenting
  • clearTaskOnLaunch
  • alwaysRetainTaskState
  • finishOnTaskLaunch

And the principal intent flags you can use are:
FLAG_ACTIVITY_NEW_TASK
FLAG_ACTIVITY_CLEAR_TOP
FLAG_ACTIVITY_SINGLE_TOP
In the following sections, you’ll see how you can use these manifest attributes and intent flags to define how activities are associated with tasks and how they behave in the back stack. Also, discussed separately are the considerations for how tasks and activities may be represented and managed in the Recents screen. See Recents Screen for more information. Normally you should allow the system to define how your task and activities are represented in the Recents screen, and you don’t need to modify this behavior. Caution: Most apps should not interrupt the default behavior for activities and tasks. If you determine that it’s necessary for your activity to modify the default behaviors, use caution and be sure to test the usability of the activity during launch and when navigating back to it from other activities and tasks with the Back button. Be sure to test for navigation behaviors that might conflict with the user’s expected behavior.
建议总结:
startActivity()可以使用 manifest element和添加flags的intent 启动,你可以使用的 属性:

  • taskAffinity
  • launchMode
  • allowTaskReparenting
  • clearTaskOnLaunch
  • alwaysRetainTaskState
  • finishOnTaskLaunch

你可以使用的intent flags:
FLAG_ACTIVITY_NEW_TASK
FLAG_ACTIVITY_CLEAR_TOP
FLAG_ACTIVITY_SINGLE_TOP
使用以上属性以便达到我们想要实现的行为。
注意,大部分应用不应该拦截activities和tasks的默认行为,如果要改变的话要通过测试验证是否是你预期的行为。

taskAffinity

android:taskAffinity 是manifest的一个元素属性,与 Activity 有着相同taskAffinity值的任务。从概念上讲,具有相同taskAffinity值的 Activity 归属同一任务(从用户的角度来看,则是归属同一“应用”)。任务的taskAffinity由其根 Activity 的taskAffinity确定。

taskAffinity确定两点内容 — Activity 更改父项后的任务(请参阅 allowTaskReparenting 属性),以及通过 FLAG_ACTIVITY_NEW_TASK
标记启动 Activity 时,用于容纳该 Activity 的任务。
默认情况下,应用中的所有 Activity 都具有相同taskAffinity值。您可以设置该属性,以不同方式将其分组,甚至可以在同一任务内放置不同应用中定义的 Activity。如要指定 Activity 与任何任务均无Affinity,请将其设置为空字符串。
如果未设置该属性,则 Activity 会继承为应用设置的Affinity(请参阅 元素的 taskAffinity 属性)。应用默认taskAffinity名称为元素所设置的软件包名称。

allowTaskReparenting

android:allowTaskReparenting是manifest的一个元素属性,作用如下:当下一次将启动 Activity 的任务转至前台时,Activity 是否能从该任务转移至与其有相似性的任务 —“true”表示可以转移,“false”表示仍须留在启动它的任务处。
如果未设置该属性,则对 Activity 应用由 元素的相应allowTaskReparenting 属性所设置的值。默认值为“false”。
正常情况下,Activity 启动时会与启动它的任务关联,并在其整个生命周期中一直留在该任务处。当不再显示现有任务时,您可以使用该属性强制 Activity 将其父项更改为与其有相似性的任务。该属性通常用于将应用的 Activity 转移至与该应用关联的主任务。
例如,如果电子邮件消息包含网页链接,则点击该链接会调出可显示该网页的 Activity。该 Activity 由浏览器应用定义,但作为电子邮件任务的一部分启动。如果将该 Activity 的父项更改为浏览器任务,则它会在浏览器下一次转至前台时显示,在电子邮件任务再次转至前台时消失。
Activity 的相似性由 taskAffinity 属性定义。通过读取任务根 Activity 的相似性即可确定任务的相似性。因此,按照定义,根 Activity 始终位于具有同一相似性的任务中。由于具有“singleTask”或“singleInstance”启动模式的 Activity 只能位于任务的根,因此更改父项仅限于“standard”和“singleTop”模式。(另请参阅 launchMode 属性。)

clearTaskOnLaunch

每当从主屏幕重新启动任务时,是否都从该任务中移除根 Activity 之外的所有 Activity —“true”表示始终将任务清除至只剩其根 Activity;“false”表示不清除。默认值为“false”。该属性只对启动新任务的 Activity(根 Activity)有意义;任务中的所有其他 Activity 均可忽略该属性。
若值为“true”,则每次当用户再次启动任务时,无论用户最后在任务中正在执行哪个 Activity,也无论用户是使用返回还是主屏幕按钮离开,系统都会将用户转至任务的根 Activity。当值为“false”时,可在某些情况下清除任务中的 Activity(请参阅 alwaysRetainTaskState 属性),但也有例外。
例如,假设用户从主屏幕启动 Activity P,然后从该处转到 Activity Q。接着,该用户按下主屏幕按钮,然后返回到 Activity P。正常情况下,用户将看到 Activity Q,因为这是其最后在 P 的任务中所执行的 Activity。不过,如果 P 将此标志设置为“true”,则当用户按下主屏幕将任务转入后台时,系统会移除 P 上方的所有 Activity(在本例中为 Q)。因此用户在返回任务时只会看到 P。
如果该属性和 allowTaskReparenting 的值均为“true”,则如上所述,任何可更改父项的 Activity 都将转移至与其有相似性的任务;而其余 Activity 随即会被移除。

alwaysRetainTaskState

系统是否始终保持 Activity 所在任务的状态 —“true”表示是,“false”表示允许系统在特定情况下将任务重置到其初始状态。默认值为“false”。该属性只对任务的根 Activity 有意义;所有其他 Activity 均可忽略该属性。
正常情况下,当用户从主屏幕重新选择某个任务时,系统会在特定情况下清除该任务(从根 Activity 上的堆栈中移除所有 Activity)。通常,如果用户在一段时间(如 30 分钟)内未访问任务,系统会执行此操作。
不过,如果该属性的值是“true”,则无论用户如何返回任务,该任务始终会显示最后一次的状态。例如,该属性非常适用于网络浏览器这类应用,因为其中存在大量用户不愿丢失的状态(如多个打开的标签)。

finishOnTaskLaunch

每当用户再次启动 Activity 的任务(在主屏幕上选择任务)时,是否应关闭(完成)现有的 Activity 实例 —“true”表示应关闭,“false”表示不应关闭。默认值为“false”。
如果此属性和 allowTaskReparenting 均为“true”,则优先使用此属性。系统会忽略 Activity 的相似性。系统不会更改 Activity 的父项,而是将其销毁。
更多属性:参见https://developer.android.com/guide/topics/manifest/activity-element.html#ltmode

Flag的作用

FLAG_ACTIVITY_NEW_TASK

从字面意思可以理解该flag的作用是启动一个新的task宿主来保存activity,那是不是只要设定了这个属性,就可以启动一个新的Task呢?答案:不是。假设现在有一个栈Task #1,里面是Activity A,B,C。此时,在C中启动D的时候,设置 FLAG_ACTIVITY_NEW_TASK
标记,此时会有两种情况,与taskAffinity相关:

  • 设置taskAffinity,D这个Activity在Manifest.xml中的声明中添加了taskAffinity,系统首先会查找有没有和D的Task Affinity相同的Task栈存在,如果有存在,将D压入那个栈.
  • 没有设置taskAffinity,默认没有设置,则会把其压入栈Task #1,变成:A B C D,这样就和标准模式效果是一样的了。

也就是说,设置了这个标志后,新启动的Activity并非就一定在新的Task中创建,如果C和D在属于同一个package,而且都是使用默认的taskAffinity,那D还是会在C的task中被创建。所以,只有C和D的taskAffinity不同时,设置了这个标志才会使D被创建到新的Task。注意如果试图从非Activity的非正常途径启动一个Activity,比如从一个Receiver中启动一个Activity,则Intent必须要添加 FLAG_ACTIVITY_NEW_TASK
标记。
更多launchMode参见:https://inthecheesefactory.com/blog/understand-android-activity-launchmode/en

FLAG_ACTIVITY_CLEAR _TASK

如果Intent中设置了这个标志,会导致含有待启动Activity的Task在Activity被启动前清空。也就是说,这个Activity会成为一个新的root,并且所有旧的activity都被finish掉。这个标志只能与FLAG_ACTIVITY_NEW_TASK 一起使用。

FLAG_ACTIVITY_ON_HOME

这个标志可以将一个新启动的任务置于当前的home任务(home activity task)之上(如果有的话)。也就是说,在任务中按back键总是会回到home界面,而不是回到他们之前看到的activity。这个标志只能与 FLAG_ACTIVITY_NEW_TASK
标志一起用。比如,C->D,如果在C启动D的时候设置了这个标志,那在D中按Back键则是直接回到home界面,而不是C。注意:只有D是在新的task中被创建时(也就是D的launchMode是singleInstance时,或者是给D指定了与C不同的taskAffinity并且加了
FLAG_ACTIVITY_NEW_TASK
标志时),使用 FLAG_ACTIVITY_ON_HOME标志才会生效。
更多FLAG:https://developer.android.com/reference/android/content/Intent.html
现在,通过以上demo实验和相关技术点的原理讲解,我们了解到启动一个activity管理他的Task和taskAffinity、FLAG、launchMode 有关:

  • taskAffinity决定了activity压入到具有相同相似性的回退栈中,具有相同taskAffinity值的 Activity 归属同一任务(从用户的角度来看,则是归属同一“应用”),应用默认taskAffinity名称为元素所设置的软件包名称。
  • FLAG和launchMode设置activity的启动方式,设置FLAG ACTIVITY
    CLEAR_TASK在系统中没有和当前activity有相似性的Task时,会创建新的Task,如果存在则压入其回退栈中,”singleTask” launchMode 的设定可以达到这个Flag相同的行为。

  • 利用这个技术点可以达到当外部调起我们的应用目标页时能创建属于自己的TASK或将目标页压入自己的回退栈中,后台任务中可以看到我们的应用。

实现外部调起应用跳转目标页面,同时能回退到应用主页

外部调起打开应用的方式上面讲解了,那如何打开指定目标页呢?来看一下我们的使用的方式和关键技术点原理。

注册事件监听

这里需要使用到 Android Activity中的 ,现在可以创建一个解析跳转的 Activity,然后需要在 Manifest 文件中配置具体的:

<activity android:name="com.wuba.StartActivity" 

          android:excludeFromRecents="true"

          android:launchMode="singleTask" 

          android:screenOrientation="portrait" >

          <intent-filter> 

              <action android:name="android.intent.action.VIEW" /> 

              <category android:name="android.intent.category.DEFAULT" /> 

              <category android:name="android.intent.category.BROWSABLE" /> 

              <data  android:host="*"

                     android:scheme="wbtest" />

          intent-filter> 

activity>

如上配置,现在这个 Activity 就具备外部唤醒的能力了,注意相关配置,外部的链接形式应该就是这样的了:wbtest://core//pagetype?params 里面还可以定义其他内容,这里就不展开说了。

外部调起启动目标落地页

看一下之前外部调起的启动流程


StartActivity是启动注册的activity,DistributeActivity是业务逻辑分发activity,按照这种外部调起的方式启动的话,大家不难看出问题,就是当启动了目标activity后回退栈中之有TargetActivity,当用户触发回退事件时,TargetActivity销毁,我们的应用也会退出,这是我们不想看到的,好不容易用户触发了回退,怎么能让应用退出呢,不逛逛我们的首页怎么能行!我们的需求便产生了,退出目标activity时要启动我们的首页HomeActivity,为解决这个问题我们来看一下之前的方案和优化后的方案。

旧方案-触发回退时判断当前activity在回退栈中是否是task root

@Override

    public boolean onBackPressed() {

        if(isReedToStartHome(mContext)) {

            ActivityUtils.startHomeActivity(mContext);

            mContext.finish();

            return true;

        }

        return false;

    }

    /*

     *  直接由外部调起的Activity,返回时需要回退到首页

     */

    public boolean isReedToStartHome(Context context){

        if(context == null)

            return false;

        Activity activity = (Activity)context;

        if(activity == null || activity.getIntent() == null)

            return false;

        if(!activity.isTaskRoot()){

            return false;

        }

        return  true;

    }

如果当前activity在回退栈中是task root则手动调起首页。

该方案有个严重缺点就是只有实现了该逻辑的activity才生效,有局限性;

新方案-使用TaskStackBuilder

通过TaskStackBuilder 可实现startActivitys 来启动多个Activity,首先就得构建一个Intent 数组,并且为每一个要启动的activity 设置好启动方式(通过flag 设置)。
TaskStackBuilder的构建

/**

 * 装载首页和目标页栈意图

 */ 

TaskStackBuilder.create(activity)

        .addParentStack(EmptyActivity.class)

        .addNextIntent(targetIntent) 

        .startActivities();

EmptyActivity的作用

 <activity android:name="com.wuba.EmptyActivity"

          android:theme="@style/Theme.Wuba.Splash"

          android:parentActivityName="com.wuba.HomeActivity"/>

TaskStackBuilder会将addParentStack方法添加的组件Manifest 中可以指定 Parent 的属性作为回退节点activity,android:parentActivityName=”com.wuba.HomeActivity”,本意是设置目标activity的属性节点,岂不是需要所有的activity都要设置一下呢?通过源码addParentStack方法分析发现,其方法只是取activity的这个属性值,这就好理解了,直接设置个带有parentActivityName属性值的activity给他就可以了,这也就是EmptyActivity的真正作用,也是关键。这样就可以将HomeActivity和TargetActivity合成 Activity 的返回栈。
流程图如下:


利用TaskStackBuilder 可构建添加HomeActivity 父节点的目标activity的intent,达到目标activity回退时能从回退栈中弹起HomeActivity的目的,就是实现我们的需求功能。

问题

一顿操作猛如虎,是不是这样设置都大功告成了呢?
看一个现象

大家注意看,在我执行外部调起前打开了一个二级大类页的,打开目标页后回退,直接回到了首页,那二级页消失了,这是怎么回事呢?看了它的源码:

public void startActivities(Bundle options) {

   if (mIntents.isEmpty()) {

      throw new IllegalStateException("No intents added to TaskStackBuilder; cannot startActivities");

   }

   Intent[] intents = mIntents.toArray(new Intent[mIntents.size()]);

   intents[0] = new Intent(intents[0]).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK | IntentCompat.FLAG_ACTIVITY_TASK_ON_HOME);

   if (!ContextCompat.startActivities(mSourceContext, intents, options)) {

       Intent topIntent = new Intent(intents[intents.length - 1]);

       topIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

       mSourceContext.startActivity(topIntent);

   }

}

通过源码可以看出这个方法每次都会给第一个Intent添加了
Intent.FLAG_ACTIVITY_NEW_TASK | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK | IntentCompat.FLAG_ACTIVITY_TASK_ON_HOME
这三个Flag,因为有
IntentCompat.FLAG_ACTIVITY_CLEAR_TASK
,所以不管你App启动没,它都是会重新启动的,也就是他重新创建了应用栈。怎么解决呢?

public class WBTaskStackBuilder {

    /**

     * 启动目标Activity时判断是否需要启动HomeActvity,以便目标activity回退时能回到首页

     * 适用于首页未启动的首次启动app场景,如外部调起、首屏广告点击启动广告页     *

     * @param intent

     * @param activity

     */

    public static void startHomeAndTargetActivity(Activity activity, Intent intent) {

        if (activity == null || intent == null) {

            return;

        }

        if (!isLaunchedActivity(activity,HomeActivity.class)&&!isHomeActivityIntent(intent)) {

            TaskStackBuilder builder = makeHomeAndTargetTaskStack(activity, intent);

            if (builder != null) {

                builder.startActivities();

                return;

            }

        }

        if (isHomeActivityIntent(intent)){

            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);

        }

        activity.startActivity(intent);

    }

    /**

     * 判断是否需要启动HomeActicvity

     * @param activity

     * @return

     */

    public static boolean isNeadStartHomeActivity(Activity activity) {

        return activity.isTaskRoot()||!ActivityTask.isHomeActivityTaskId(activity.getTaskId());//是否首次启动,只有launchActivity在栈内,第二个判断是解决bug,魅族手机从web调起,taskid和浏览器的一致,而且不是根Activity。

    }

    /**

     * 装载首页和目标页栈意图

     * @param activity

     * @param intent

     * @return

     */

    public static TaskStackBuilder makeHomeAndTargetTaskStack(Activity activity, Intent intent) {

        return TaskStackBuilder.create(activity)

                .addParentStack(EmptyActivity.class)

                .addNextIntent(intent);


} private static boolean isHomeActivityIntent(Intent intent){ if (HomeActivity.class.getName().equals(intent.getComponent().getClassName())||"com.wuba.home.ACTION_HOME".equals(intent.getAction())){ return true; } return false; } public static boolean isLaunchedActivity(@NonNull Context context, Class clazz) { Intent intent = new Intent(context, clazz); ComponentName cmpName = intent.resolveActivity(context.getPackageManager()); boolean flag = false; if (cmpName != null) { ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); List taskInfoList = am.getRunningTasks(20); for (ActivityManager.RunningTaskInfo taskInfo : taskInfoList) { if (taskInfo.baseActivity.equals(cmpName)) { flag = true; break; } } } return flag; } }

其实很简单的,我们在跳转的时候先判断下当前App是否已经开启过了,没有则使用TaskStackBuilder启动目标activity,有的话,那就不用再去创建堆栈了,直接启动目标activity。注意StartActivity设置了singTask的launchMode,可以起到
Intent.FLAG_ACTIVITY_NEW_TASK
的作用(这个我们上面讲解过了),这样就可以避免将应用的activity压入浏览器所在的堆栈里。上视频结果: