Mingxiu's "stack" way - overcoming the Android startup stack trap

Author: vivo Internet front-end team - Zhao Kaiping


This article starts from a problem encountered in a business case, and uses the flag FLAG_ACTIVITY_NEW_TASK as an entry point to take you to explore an important task before the Activity starts-stack verification.


The article lists a series of abnormal conditions that may be encountered in the business, describes in detail the "pit" that may be encountered when using FLAG_ACTIVITY_NEW_TASK, and explores its root cause from the source code. Only by using flag and launchMode reasonably can we avoid a series of unexpected startup problems due to the particularity of the stack mechanism.


1. Problem and background


Interconnection and mutual jump between applications is an important means to achieve system integrity and experience consistency, and it is also the simplest method.


When we use the most common method to startActivity, we will encounter failure. In real business, we encountered such an exception: when a user clicks a button, he wants to "simplely" jump to another application, but nothing happens.


With rich experience, do you have various conjectures in your mind: Is it because the target Activity or even the target App does not exist? Is the target Activty not open to the outside world? Is there a permission restriction or the action/uri of the jump is wrong...


The real reason is hidden by layers of features such as flag, launchMode, and Intent, which may exceed your thinking at this time.


This article will start from the source code, explore the cause and effect, expand and talk about the "tribulations" that need to go through before startActivity() is really ready to start an Activity, and how to solve the startup exception caused by the stack problem according to the evidence.


1.1 Problems encountered in business


The scenario in the business is like this, there are three applications A, B, and C.

(1) Jump from application A-Activity1 to application B-Activity2;

(2) Application B-Activity2 continues to jump to application C-Activity3;

(3) A button in C will jump to B-Activity2 again, but nothing happens after clicking. If you don't go through the previous jump from A to B, it is possible for C to jump directly to B.



1.2 Question Code


3个Activity的Androidmanifest配置如下,均可通过各自的action拉起,launchMode均为标准模式。

<!--应用A-->       <activity            android:name=".Activity1"            android:exported="true">            <intent-filter>                <action android:name="com.zkp.task.ACTION_TO_A_PAGE1" />                <category android:name="android.intent.category.DEFAULT" />            </intent-filter>        </activity> <!--应用B-->        <activity            android:name=".Activity2"            android:exported="true">            <intent-filter>                <action android:name="com.zkp.task.ACTION_TO_B_PAGE2" />                <category android:name="android.intent.category.DEFAULT" />            </intent-filter>        </activity> <!--应用C-->        <activity            android:name=".Activity3"            android:exported="true">            <intent-filter>                <action android:name="com.zkp.task.ACTION_TO_C_PAGE3" />                <category android:name="android.intent.category.DEFAULT" />            </intent-filter>        </activity>


A-1到B-2的代码,指定flag为

FLAG_ACTIVITY_NEW_TASK

private void jumpTo_B_Activity2_ByAction_NewTask() {    Intent intent = new Intent();    intent.setAction("com.zkp.task.ACTION_TO_B_PAGE2");    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);    startActivity(intent);}


B-2到C-3的代码,未指定flag

private void jumpTo_C_Activity3_ByAction_NoTask() {    Intent intent = new Intent();    intent.setAction("com.zkp.task.ACTION_TO_C_PAGE3");    startActivity(intent);}


C-3到B-2的代码,与A-1到B-2的完全一致,指定flag为 FLAG_ACTIVITY_NEW_TASK

private void jumpTo_B_Activity2_ByAction_NewTask() {    Intent intent = new Intent();    intent.setAction("com.zkp.task.ACTION_TO_B_PAGE2");    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);    startActivity(intent);}


1.3 代码初步分析


仔细查看问题代码,在实现上非常简单,有两个特征:

(1)如果直接通过C-3跳B-2,没有任何问题,但A-1已经跳过B-2后,C-3就失败了。

(2)在A-1和C-3跳到B-2时,都设置了flag为FLAG_ACTIVITY_NEW_TASK。


依据经验,我们推测与栈有关,尝试将跳转前栈的状态打印出来,如下图。



由于A-1跳到B-2时设置了FLAG_ACTIVITY_NEW_TASK,B-2跳到C-3时未设置,所以1在独立栈中,2、3在另一个栈中。示意如下图。



C-3跳转B-2一般有3种可能的预期,如下图:预想1,新建一个Task,在新Task中启动一个B-2;预想2,复用已经存在的B-2;预想3,在已有Task中新建一个实例B-2。



但实际上3种预期都没有实现,所有Activity的任何声明周期都没有变化,界面始终停留在C-3。


看一下FLAG_ACTIVITY_NEW_TASK的官方注释和代码注释,如下图:




重点关注这一段:

When using this flag, if a task is already running for the activity you are now starting, then a new activity will not be started; instead, the current task will simply be brought to the front of the screen with the state it was last in.


使用此flag时,如果你正在启动的Activity已经在一个Task中运行,那么一个新Activity不会被启动;相反,当前Task将简单地显示在界面的前面,并显示其最后的状态。


——显然,官方文档与代码注释的表述与我们的异常现象是一致的,目标Activity2已经在Task中存在,则不会被启动;Task直接显示在前面,并展示最后的状态。由于目标Activty3就是来源Activity3,所以页面没有任何变化。


看起来官方还是很靠谱的,但实际效果真的能一直与官方描述一致吗?我们通过几个场景来看一下。


二、场景拓展与验证


2.1 场景拓展


在笔者依据官方描述进行调整、复现的过程中,发现了几个比较有意思的场景。


PS:上面业务的案例中,B-2和C-3在不同应用内,又在相同的Task内,但实际上是否是同一个应用,对结果的影响并不大。为了避免不同应用和不同Task造成阅读混乱,同一个栈的跳转,我们都在本应用内进行,故业务中的场景等价于下面的【场景0】


【场景0】把业务中B-2到C-3的应用间跳转改为B-2到B-3的应用内跳转

// B-2跳转B-3public static void jumpTo_B_3_ByAction_Null(Context context) {    Intent intent = new Intent();    intent.setAction("com.zkp.task.ACTION_TO_B_PAGE3");    context.startActivity(intent);}


如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,最终设置NEW_TASK想跳转B-2。虽然跳C-3改为了跳B-3,但与之前问题的表现一致,没有反应,停留在B-3。



有的读者会指出这样的问题:如果同一个应用内使用NEW_TASK跳转,而不指定目标的taskAffinity属性,实际是无法在新Task中启动的。请大家忽略该问题,可以认为笔者的操作是已经加了taskAffinity的,这对最终结果并没有影响。


【场景1】如果目标Task和来源Task不是同一个,情况是否会如官方文档所说复用已有的Task并展示最近状态?我们改为B-3启动一个新Task的新Activity C-4,再通过C-4跳回B-2

// B-3跳转C-4public static void jumpTo_C_4_ByAction_New(Context context) {    Intent intent = new Intent("com.zkp.task.ACTION_TO_C_PAGE4");    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);    context.startActivity(intent);}// C-4跳转B-2public static void jumpTo_B_2_ByAction_New(Context context) {    Intent intent = new Intent();    intent.setAction("com.zkp.task.ACTION_TO_B_PAGE2");    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);    context.startActivity(intent);}


如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-2。



预想的结果是:不会跳到B-2,而是跳到它所在Task的顶层B-3。

实际的结果是:与预期一致,确实是跳到了B-3。


【场景2】把场景1稍做修改:C-4到B-2时,我们不通过action来跳,改为通过setClassName跳转

// C-4跳转B-2public static void jumpTo_B_2_ByPath_New(Context context) {    Intent intent = new Intent();    intent.setClassName("com.zkp.b", "com.zkp.b.Activity2"); // 直接设置classname,不通过action    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);    context.startActivity(intent);}


如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-2。



预想的结果是:与场景0一致,会跳到B-2所在Task的已有顶层B-3。

实际的结果是:在已有的Task2中,产生了一个新的B-2实例。


仅仅是改变了一下重新跳转B-2的方式,效果就完全不一样了!这与官方文档中提到该flag与"singleTask" launchMode值产生的行为并不一致!


【场景3】把场景1再做修改:这次C-4不跳栈底的B-2,改为跳转B-3,且还是通过action方式。

// C-4跳转B-3public static void jumpTo_B_3_ByAction_New(Context context) {    Intent intent = new Intent();    intent.setAction("com.zkp.task.ACTION_TO_B_PAGE3");    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);    context.startActivity(intent);}


如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-3。



预想的结果是:与场景0一致,会跳到B-2所在Task的顶层B-3。

实际的结果是:在已有的Task2中,产生了一个新的B-3实例。


不是说好的,Activity已经存在时,展示其所在Task的最新状态吗?明明Task2中已经有了B-3,并没有直接展示它,而是生成了新的B-3实例。


【场景4】既然Activity没有被复用,那Task一定会被复用吗?把场景3稍做修改,直接给B-3指定一个单独的affinity。

<activity    android:name=".Activity3"    android:exported="true"    android:taskAffinity="b3.task"><!--指定了亲和性标识-->    <intent-filter>        <action android:name="com.zkp.task.ACTION_TO_B_PAGE3" />        <category android:name="android.intent.category.DEFAULT" />    </intent-filter></activity>


如下图,A-1设置NEW_TASK跳转B-2,再跳转B-3,再设置NEW_TASK跳转C-4,最终设置NEW_TASK想跳转B-3。



——这次,连Task也不会再被复用了……Activity3在一个新的栈中被实例化了。


再回看官方的注释,就会显得非常不准确,甚至会让开发者对该部分的认知产生严重错误!稍微改变过程中的某个毫无关联的属性(如跳转目标、跳转方式……),就会产生很大差异。


在看flag相关注释时,我们要树立一个意识:Task和Activity跳转的实际效果,是launchMode、taskAffinity、跳转方式、Activity在Task中的层级等属性综合作用的结果,不要相信“一面之词”。


回到问题本身,究竟是哪些原因造就了上面的不同效果呢?只有源码最值得信赖了。


三、场景分析与源码探索


本文以Android 12.0源码为基础,进行探究。上述场景在不同Android版本上的表现是一致的。


3.1 源码调试注意事项


源码的调试方法,许多文章已经有了详细的教学,本文不再赘述。此处只简单总结其中需要注意的事项

  1. 下载模拟器时,不要使用Google Play版本,该版本类似user版本,无法选择system_process进程进行断点。


  2. 即使是Google官方模拟器和源码,在断点时,也会有行数严重不对应的情况(比如:模拟器实际会运行到方法A,但在源码中打断点时,实际不能定位到方法A的对应行数),该问题并没有很好的处理方法,只能尽量规避,如使模拟器版本与源码版本保持一致、多打一些断点增加关键行数被定位到的几率。


3.2 初步断点,明确启动结果


以【场景0】为例,我们初步确认一下,为什么B-3跳转B-2会无反应,系统是否告知了原因。


3.2.1 明确启动结果及其来源


在Android源码的断点调试中,常见的有两类进程:应用进程和system_process进程。


在应用进程中,我们能获取到应用启动结果的状态码result,这个result用来告诉我们启动是否成功。涉及堆栈如下图(标记1)所示:

Activity类::startActivity() 

→ startActivityForResult() 

→ Instrumentation类::execStartActivity(),

返回值result则是ATMS

(ActivityTaskManagerService)执行的结果。



如上图(标记2)标注,ATMS类::startActivity()方法,返回了result=3。


在system_process进程中,我们看一下这个result=3是怎样被赋值的。略去详细断点步骤,实际堆栈如下图(标注1)所示:


ATMS类::startActivity() → startActivityAsUser() 

→ ActivityStarter类::execute() 

→ executeRequest() 

→ startActivityUnchecked() 

→ startActivityInner() 

→ recycleTask(),在recycleTask()中返回了结果。



如上图(标注2)所示,result在mMovedToFront=false时被赋值,即result=START_DELIVERED_TO_TOP=3,而START_SUCCESS=0才代表创建成功。


看一下源码中对START_DELIVERED_TO_TOP的说明,如下图:



Result for IActivityManaqer.startActivity: activity wasn't really started, but the given Intent was given to the existing top activity.

(IActivityManaqer.startActivityActivity的结果:Activity并未真正启动,但给定的Intent已提供给现有的顶层Activity。)


“Activity并未真正启动”——是的,因为可以复用


“给定的Intent已提供给现有的顶层Activity”——实际没有,顶层Activity3并没有收到任何回调,onNewIntent()未执行,甚至尝试通过Intent::putExtra()传入新的参数,Activity3也没有收到。官方文档又带给了我们一个疑问点?我们把这个问题记录下来,在后面分析。


满足什么条件,才会造成

START_DELIVERED_TO_TOP的结果呢?笔者的思路是,通过与正常启动流程对比,找出差异点。


3.3 过程断点,探索启动流程


一般来说,在定位问题时,我们习惯通过结果反推原因,但反推的过程只能关注到与问题强关联的代码分支,并不能能使我们很好地了解全貌。


所以,本节内容我们通过顺序阅读的方法,正向介绍startActivity过程中与上述【场景01234】强相关的逻辑。再次简述一下:

  1. 【场景0】同一个Task内,从顶部B-3跳转B-2——停留在B-3

  2. 【场景1】从另一个Task内的C-4,跳转B-2——跳转到B-3

  3. 【场景2】把场景1中,C-4跳转B-2的方式改为setClassName()——创建新B-2实例

  4. 【场景3】把场景1中,C-4跳转B-2改为跳转B-3——创建新B-3实例

  5. 【场景4】给场景3中的B-3,指定taskAffinity——创建新Task和新B-3实例


3.3.1 流程源码概览


源码中,整个启动流程很长,涉及的方法和逻辑也很多,为了便于大家理清方法调用顺序,方便后续内容的阅读,笔者将本文涉及到的关键类及方法调用关系整理如下。


后续阅读中如果不清楚调用关系,可以返回这里查看:

// ActivityStarter.java     ActivityStarter::execute() {        executeRequest(intent) {            startActivityUnchecked() {                startActivityInner();        }    }    ActivityStarter::startActivityInner() {        setInitialState();        computeLaunchingTaskFlags();        Task targetTask = getReusableTask(){            findTask();        }        ActivityRecord targetTaskTop = targetTask.getTopNonFinishingActivity();        if (targetTaskTop != null) {            startResult = recycleTask() {                setTargetRootTaskIfNeeded();                complyActivityFlags();                if (mAddingToTask) {                    return START_SUCCESS; //【场景2】【场景3】从recycleTask()返回                }                resumeFocusedTasksTopActivities()                return mMovedToFront ? START_TASK_TO_FRONT : START_DELIVERED_TO_TOP;//【场景1】【场景0】从recycleTask()返回            }        } else {            mAddingToTask = true;        }        if (startResult != START_SUCCESS) {            return startResult;//【场景1】【场景0】从startActivityInner()返回        }        deliverToCurrentTopIfNeeded();        resumeFocusedTasksTopActivities();        return startResult;    }


3.3.2 关键流程分析


(1)初始化


startActivityInner()是最主要的方法,如下列几张图所示,该方法会率先调用setInitialState(),初始化各类全局变量,并调用reset(),重置ActivityStarter中各种状态。


在此过程中,我们记下两个关键变量mMovedToFront和mAddingToTask,它们均在此被重置为false。


其中,mMovedToFront代表当Task可复用时,是否需要将目标Task移动到前台;mAddingToTask代表是否要将Activity加入到Task中。





(2)计算确认启动时的flag


该步骤会通过computeLaunchingTaskFlags()方法,根据launchMode、来源Activity的属性等进行初步计算,确认LaunchFlags。


此处重点处理来源Activity为空的各类场景,与我们上文中的几种场景无关,故不再展开讲解。


(3)获取可以复用的Task


该步骤通过调用getReusableTask()实现,用来查找有没有可以复用的Task。


先说结论:场景0123中,都能获取到可以复用的Task,而场景4中,未获取到可复用的Task。


为什么场景4不可以复用?我们看一下getReusableTask()的关键实现。



上图(标注1)中,putIntoExistingTask代表是否能放入已经存在的Task。当flag含有NEW_TASK且不含MULTIPLE_TASK时,或指定了singleInstance或singleTask的launchMode等条件,且没有指定Task或要求返回结果 时,场景01234均满足了条件。


然后,上图(标注2)通过findTask()查找可以复用的Task,并将过程中找到的栈顶Activity赋值给intentActivity。最终,上图(标注3)将intentActivity对应的Task作为结果。


findTask()是怎样查找哪个Task可以复用呢?



主要是确认两种结果mIdealRecord——“理想的ActivityRecord”  和 mCandidateRecord——"候选的ActivityRecord",作为intentActivity,并取intentActivity对应的Task作为复用Task。


什么ActivityRecord才是理想或候选的ActivityRecord呢?

在mTmpFindTaskResult.process()中确认。



程序会将当前系统中所有的Task进行遍历,在每个Task中,进行如上图所示的工作——将Task的底部Activity realActivity与目标Activity cls进行对比。


场景012中,我们想跳转Activity2,即cls是Activity2,与Task底部的realActivity2相同,则将该Task顶部的Activity3 r作为“理想的Activity”;


场景3中,我们想跳转Activity3,即cls是Activity3,与Task底部的realActivity2不同,则进一步判断task底部Activity2与目标Activity3的栈亲和行,具有相同亲和性,则将Task的顶部Activity3作为“候选Activity”;


场景4中,所有条件都不满足,最终没能找到可复用的Task。在执行完getReusableTask()后将mAddingToTask赋值为true


由此,我们就能解释【场景4】中,新建了Task的现象。


(4)确定是否需要将目标Task移动到前台


如果存在可复用的Task,场景0123会执行recycleTask(),该方法中会相继进行几个操作:setTargetRootTaskIfNeeded()、

complyActivityFlags()。


首先,程序会执行

setTargetRootTaskIfNeeded(),用来确定是否需要将目标Task移动到前台,使用mMovedToFront作为标识。




在【场景123】中,来源Task和目标Task是不同的,differentTopTask为true,再经过一系列Task属性对比,能够得出mMovedToFront为true;


而场景0中,来源Task和目标Task相同,differentTopTask为false,mMovedToFront保持初始的false。


由此,我们就能解释【场景0】中,Task不会发生切换的现象。


(5)通过对比flag、Intent、Component等确认是否要将Activity加入到Task中


还是在【场景0123】中,recycleTask()会继续执行complyActivityFlags(),用来确认是否要将Activity加入到Task中,使用mAddingToTask作为标识。


该方法会对FLAG_ACTIVITY_NEW_TASK、

FLAG_ACTIVITY_CLEAR_TASK、

FLAG_ACTIVITY_CLEAR_TOP等诸多flag、Intent信息进行一系列判断。




上图(标注1)中,会先判断后续是否需要重置Task,resetTask,判断条件则是FLAG_ACTIVITY_RESET_TASK_IF_NEEDED,显然,场景0123的resetTask都为false。继续执行。


接着,会有多种条件判断按顺序执行。


在【场景3】中,目标Component(mActivityComponent)是B-3,目标Task的realActivity则是B-2,两者不相同,进入了resetTask相关的判断(标注2)。


之前resetTask已经是false,故【场景3】的mAddingToTask脱离原始值,被置为true。


在【场景012】中,相对比的两个Activity都是B-2(标注3),可以进入下一级判断——isSameIntentFilter()。




这一步判断的内容就很明显了,目标Activity2的已有Intent 与 新的Intent做对比。很显然,场景2中由于改为了setClassName跳转,Intent自然不一样了。


故【场景2】的mAddingToTask脱离原始值,被置为true。


总结看一下:

【场景123】的mMovedToFront最先被置为true,而【场景0】经历重重考验,保持初始值为false。

——这意味着当有可复用Task时,【场景0】不需要把Task切换到前列;【场景123】需要切换到目标Task。


【场景234】的mAddingToTask分别在不同阶段被置为true,而【场景01】,始终保持初始值false。

——这意味着,【场景234】需要将Activity加入到Task中,而【场景01】不再需要。


(6)实际启动Activity或直接返回结果


被启动的各个Activity会通过resumeFocusedTasksTopActivities()等一系列操作,开始真正的启动与生命周期的调用。


我们关于上述各个场景的探索已经得到答案,后续流程便不再关注。


四、问题修复及遗留问题解答


4.1 问题修复


既然前面总结了这么多必要条件,我们只需要破坏其中的某些条件,就可以修复业务中遇到的问题了,简单列举几个的方案。


  • 方案一:修改flag。B-3跳转B-2时,增加FLAG_ACTIVITY_CLEAR_TASK或FLAG_ACTIVITY_CLEAR_TOP,或者直接不设置flag。经验证可行。

  • 方案二:修改intent属性,即【场景2】。A-1通过action方式隐式跳转B-2,则B-3可以通过setClassName方式,或修改action内属性的方式跳转B-2。经验证可行。

  • 方案三:提前移除B-2。B-2跳转B-3时,finish掉B-2。需要注意的是,finish()要在startActivity()之前执行,以避免遗留的ActivityRecord和Intent信息对后续跳转的影响。尤其是当你把B-2作为自己应用的deeplink分发Activity时,更值得警惕。


4.2 遗留问题


还记得我们在文章开端的某个疑惑吗,为什么没有回调onNewIntent()?


onNewIntent() 会通过deliverNewIntent()触发,而deliverNewIntent()仅通过以下两个方法调用。



complyActivityFlags()就是上文3.3.1.5中我们着重探讨的方法,可以发现complyActivityFlags()中所有可能调用deliverNewIntent()的条件均被完美避开了。


而deliverToCurrentTopIfNeeded()方法则如下图所示。



mLaunchFlags和mLaunchMode,无法满足条件,导致dontStart为false,无缘

deliverNewIntent()。


至此,onNewIntent()的问题得到解答。


五、结语


通过一系列场景假设,我们发现了许多出乎意料的现象:

  1. 文档提到FLAG_ACTIVITY_NEW_TASK等价于singleTask,与事实并不完全如此,只有与其他flag搭配才能达到相似的效果。这一flag的注释非常片面,甚至会引发误解,单一因素无法决定整体表现。

  2. 官方文档提到

    START_DELIVERED_TO_TOP会将新的Intent传递给顶层Activity,但事实上,并不是每一种START_DELIVERED_TO_TOP都会把新的Intent重新分发。

  3. 同一个栈底Activity,前后两次都通过action或都通过setClassName跳转到时,第二次跳转竟然会失败,而两次用不同方式跳转时,则会成功。

  4. 单纯使用FLAG_ACTIVITY_NEW_TASK时,跳栈底Activity和跳同栈内其他Activity的效果大相径庭。


业务中遇到的问题,归根结底就是对Android栈机制不够了解造成的。


在面对栈相关的编码时,开发者务必要想清楚,承担新开应用栈的Activty在应用全局承担怎样的使命,要对Task历史、flag属性、launchMode属性、Intent内容等全面评估,谨慎参考官方文档,才能避免栈陷阱,达成理想可靠的效果。



END


猜你喜欢


本文分享自微信公众号 - vivo互联网技术(vivoVMIC)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

{{o.name}}
{{m.name}}

Guess you like

Origin my.oschina.net/vivotech/blog/8659210