本课时我们主要讲解 Android 通过 Activity 进行交互时的一些问题。
相信对于 Android 工程师来说,startActivity 就如同初恋一般,要求低、见效快。是每一个 Android 工程师从青葱少年迈向成熟大叔必经阶段。遥想 2010 年,我也是凭着一手 startActivity 技能玩的特别好,成功俘获了多家公司的芳心。这么多年过去了,在谷歌的调教下,startActivity 也变得越发成熟和丰满,对工程师的要求也越来越高。这节课就来看下使用 startActivity 时都有哪些需要注意的点。
taskAffinity对于 Activity 的启动模式,每一个 Android 工程师都非常熟悉。通过设置不同的启动模式可以实现调配不同的 Task。但是 taskAffinity 在一定程度上也会影响任务栈的调配流程。
每一个 Activity 都有一个 Affinity 属性,如果不在清单文件中指定,默认为当前应用的包名。taskAffinity 主要有以下几点需要注意:
taskAffinity 会默认使 Activity 在新的栈中分配吗?可以通过一个例子来验证一下,在一个 Android 项目 LagouTaskAffinity 中,创建两个 Activity:First 和 Second,它们的具体配置如下:
除了 Activity 类名之外,其他都是默认配置。这种情况下,点击 First 中的 Button,从 First 页面跳转到 Second 页面。
然后在命令行执行以下命令:
adb shell dumpsys activity activities
上述命令会将系统中所有存活中的 Activity 信息打印到控制台,具体结果如下:
上图中的 TaskRecord 代表一个任务栈,在这个栈中存在两个 Activity 实例:First 和 Second,并且 Second 处于栈顶。
接下来将 Second 的 taskAffinity 修改一下,如下所示:
我将 Second 的 taskAffinity 修改为 ”lagou.affinity“,使它和 First 的 taskAffinity 不同。重新运行代码,并再次查看任务栈中的情况,结果如下:
可以看出,虽然 First 和 Second 的 taskAffinity 不同,但是它们都被创建在一个任务栈中。
但如果我再将 Second 的 launchMode 改为 singleTask,再次重新运行则会发现两个 Activity 会被分配到不同的任务栈中,如下所示:
taskAffinity + allowTaskReparenting结论:单纯使用 taskAffinity 不能导致 Activity 被创建在新的任务栈中,需要配合 singleTask 或者 singleInstance!
allowTaskReparenting 赋予 Activity 在各个 Task 中间转移的特性。一个在后台任务栈中的 Activity A,当有其他任务进入前台,并且 taskAffinity 与 A 相同,则会自动将 A 添加到当前启动的任务栈中。这么说比较抽象,举一个生活中的场景:
- 在某外卖 App 中下好订单后,跳转到支付宝进行支付。当在支付宝中支付成功之后,页面停留在支付宝支付成功页面。
- 按 Home 键,在主页面重新打开支付宝,页面上显示的并不是支付宝主页面,而是之前的支付成功页面。
- 再次进入外卖 App,可以发现支付宝成功页面已经消失。
造成上面现象的原因就是 allowTaskReparenting 属性,还是通过代码案例来演示。
分别创建 2 个 Android 工程:First 和 TaskAffinityReparent:
- 在 First 中有 3 个 Activity:FirstA、FirstB、FirstC。打开顺序依次是 FirstA -> FirstB -> FirstC。其中 FirstC 的 taskAffinity 为”lagou.affinity“,且 allowTaskReparenting 属性设置为true。FirstA 和 FirstB 为默认值;
- TaskAffinityReparent 中只有一个 Activity--ReparentActivity,并且其 TaskAffinity 也等于”lagou.affinity“。
将这两个项目分别安装到手机上之后,打开 First App,并从 FirstA 开始跳转到 FirstB,再进入 FirstC 页面。然后按 Home 键,使其进入后台任务。此时系统中的 Activity 信息如下:
接下来,打开 TaskAffinityReparent 项目,屏幕上本应显示 ReparentActivity 的页面内容,但是实际上显示的却是 FirstC 中的页面内容,并且系统中 Activity 信息如下:
可以看出,FirstC 被移动到与 ReparentActivity 处在一个任务栈中。此时 FirstC 位于栈顶位置,再次点击返回键,才会显示 ReparentActivity 页面。
通过 Binder 传递数据的限制 Binder 传递数据限制Activity 界面跳转时,使用 Intent 传递数据是最常用的 *** 作了。但是 Intent 传值偶尔也会导程序崩溃,比如以下代码:
在 startFirstB 方法中,跳转 FirstB 页面,并通过 Intent 传递 Bean 类中的数据。但是执行上述代码会报如下错误:
上面 log 日志的意思是 Intent 传递数据过大,最终原因是 Android 系统对使用 Binder 传数据进行了限制。通常情况为 1M,但是根据不同版本、不同厂商,这个值会有区别。
解决办法:- 减少通过 Intent 传递的数据,将非必须字段使用 transient 关键字修饰。
比如上述 Bean 类中,假如 byte[] data 并非必须使用的数据,则需要避免将其序列化,如下所示:
添加 transient 修饰之后,再次运行代码则不会再报异常。
- 将对象转化为 JSON 字符串,减少数据体积。
因为 JVM 加载类通常会伴随额外的空间来保存类相关信息,将类中数据转化为 JSON 字符串可以减少数据大小。比如使用 Gson.toJson 方法。
大多时候,将类转化为 JSON 字符串之后,还是会超出 Binder 限制,说明实际需要传递的数据是很大的。这种情况则需要考虑使用本地持久化来实现数据共享,或者使用 EventBus 来实现数据传递。
关于 Binder 机制的原理分析。可以参考网上以下两篇文章:
- 老罗 Binder源码分析
- 听说你 Binder 机制学的不错,来面试下这几个问题
一直以来,我们经常会在自定义的 Application 中做一些初始化的 *** 作。比如 App 分包、推送初始化、图片加载库的全局配置等,如下所示:
但实际上,Activity 可以在不同的进程中启动,而每一个不同的进程都会创建出一个 Application,因此有可能造成 Application 的 onCreate 方法被执行多次。比如以下代码:
RemoteActivity 的 process 为“lagou.process”,这将导致它会在一个新的进程中创建。当在 MainActivity 中跳转到 RemoteActivity 时,LagouApplication 会被再次创建,其代码如下:
最终打印日志如下:
可以看出 LagouApplication 的 onCreate 方法被创建了 2 次,因此各种初始化的 *** 作也会被执行 2 遍。
针对这个问题,目前有两种比较好的处理方式:
- onCreate 方法中判断进程的名称,只有在符合要求的进程里,才执行初始化 *** 作;
- 抽象出一个与 Application 生命周期同步的类,并根据不同的进程创建相应的 Application 实例。
更多详细介绍可以参考这篇文章:解决 Android 多进程导致 Application 重复创建问题
后台启动 Activity 失效试想一下,如果我们正在玩着游戏,此时手机后台可能有个下载某 App 的任务在执行。当 App 下载完之后突然d出安装界面,中断了游戏界面的交互,这种情况会造成用户体验极差,而最终用户的吐槽对象都会转移到 Android 手机或者 Android 系统本身。
为了避免这种情况的发生,从 Android10(API 29)开始,Android 系统对后台进程启动 Activity 做了一定的限制。官网对其介绍如下:
主要目的就是尽可能的避免当前前台用户的交互被打断,保证当前屏幕上展示的内容不受影响。
但是这也造成了很多实际问题,在我们项目中有 Force Update 功能,当用户选择升级之后会在后台进行新的安装包下载任务。正常情况下下载成功需要d出 apk 安装界面,但是在某一版升级时突然很多用户反馈无法d出下载界面。经过查看抓取的 log 信息,最终发现有个特点就是都发生在 Android 10 版本,因此怀疑应该是版本兼容问题,最终谷歌搜索,发现果然如此。
解决办法:
Android 官方建议我们使用通知来替代直接启动 Activity *** 作:
也就是当后台执行的任务执行完毕之后,并不会直接调用 startActivity 来启动新的界面,而是通过 NotificationManager 来发送 Notification 到状态栏。这样既不会影响当前使用的交互 *** 作,用户也能及时获取后台任务的进展情况,后续的 *** 作由用户自己决定。
总结本课时主要总结了几个使用 startActivity 时可能会遇到的问题:
- taskAffinity 实现任务栈的调配;
- 通过 Binder 传递数据的限制;
- 多进程应用可能会造成的问题;
- 后台启动 Activity 的限制。
第14讲:彻底掌握 Android touch 事件分发时序
Android touch 事件的分发是 Android 工程师必备技能之一。关于事件分发主要有几个方向可以展开深入分析:
-
touch 事件是如何从驱动层传递给 Framework 层的 InputManagerService;
-
WMS 是如何通过 ViewRootImpl 将事件传递到目标窗口;
-
touch 事件到达 DecorView 后,是如何一步步传递到内部的子 View 中的。
其中与上层软件开发息息相关的就是第 3 条,也是本课时的重点关注内容。
思路梳理注意:不同版本间的代码会有区别,本文是基于 Android-28 的源码上进行分析。
在深入分析事件分发源码之前,需要先弄清楚 2 个概念。
ViewGroupViewGroup 是一组 View 的组合,在其内部有可能包含多个子 View,当手指触摸屏幕上时,手指所在的区域既能在 ViewGroup 显示范围内,也可能在其内部 View 控件上。
因此它内部的事件分发的重心是处理当前 Group 和子 View 之间的逻辑关系:
-
当前 Group 是否需要拦截 touch 事件;
-
是否需要将 touch 事件继续分发给子 View;
-
如何将 touch 事件分发给子 View。
View 是一个单纯的控件,不能再被细分,内部也并不会存在子 View,所以它的事件分发的重点在于当前 View 如何去处理 touch 事件,并根据相应的手势逻辑进行一些列的效果展示(比如滑动,放大,点击,长按等)。
-
是否存在 TouchListener;
-
是否自己接收处理 touch 事件(主要逻辑在 onTouchEvent 方法中)。
整个 View 之间的事件分发,实质上就是一个大的递归函数,而这个递归函数就是 dispatchTouchEvent 方法。在这个递归的过程中会适时调用 onInterceptTouchEvent 来拦截事件,或者调用 onTouchEvent 方法来处理事件。
先从宏观角度,纵览整个 dispatch 的源码如下:
如代码中的注释,dispatch 主要分为 3 大步骤:
-
步骤 1:判断当前 ViewGroup 是否需要拦截此 touch 事件,如果拦截则此次 touch 事件不再会传递给子 View(或者以 CANCEL 的方式通知子 View)。
-
步骤 2:如果没有拦截,则将事件分发给子 View 继续处理,如果子 View 将此次事件捕获,则将 mFirstTouchTarget 赋值给捕获 touch 事件的 View。
-
步骤 3:根据 mFirstTouchTarget 重新分发事件。
-
接下来详细的看下每一个步骤:
图中红框标出了是否需要拦截的条件:
-
如果事件为 DOWN 事件,则调用 onInterceptTouchEvent 进行拦截判断;
-
或者 mFirstTouchTarget 不为 null,代表已经有子 View 捕获了这个事件,子 View 的 dispatchTouchEvent 返回 true 就是代表捕获 touch 事件。
如果在上面步骤 1 中,当前 ViewGroup 并没有对事件进行拦截,则执行步骤 2。
步骤 2 具体代码如下仔细看上述的代码可以看出:
-
图中 ① 处表明事件主动分发的前提是事件为 DOWN 事件;
-
图中 ② 处遍历所有子 View;
-
图中 ③ 处判断事件坐标是否在子 View 坐标范围内,并且子 View 并没有处在动画状态;
-
图中 ④ 处调用 dispatchTransformedTouchEvent 方法将事件分发给子 View,如果子 View 捕获事件成功,则将 mFirstTouchTarget 赋值给子 View。
步骤 3 有 2 个分支判断。
-
分支 1:如果此时 mFirstTouchTarget 为 null,说明在上述的事件分发中并没有子 View 对事件进行了捕获 *** 作。这种情况下,直接调用 dispatchTransformedTouchEvent 方法,并传入 child 为 null,最终会调用 super.dispatchTouchEvent 方法。实际上最终会调用自身的 onTouchEvent 方法,进行处理 touch 事件。也就是说:如果没有子 View 捕获处理 touch 事件,ViewGroup 会通过自身的 onTouchEvent 方法进行处理。
-
分支 2:mFirstTouchTarget 不为 null,说明在上面步骤 2 中有子 View 对 touch 事件进行了捕获,则直接将当前以及后续的事件交给 mFirstTouchTarget 指向的 View 进行处理。
定义如下布局文件:
DownInterceptedGroup 和 CaptureTouchView 是两个自定义 View,它们的源码分别如下:
手指触摸 CaptureTouchView 并滑动一段距离后抬起,最终打印 log 如下:
上图中在 DOWN 事件中,DownInterceptGroup 的 onInterceptTouchEvent 被触发一次;然后在子 View CaptureTouchView 的 dispatchTouchEvent 中返回 true,代表它捕获消费了这个 DOWN 事件。这种情况下 CaptureTouchView 会被添加到父视图(DownInterceptGroup)中的 mFirstTouchTarget 中。因此后续的 MOVE 和 UP 事件都会经过 DownInterceptGroup 的 onInterceptTouchEvent 进行拦截判断。 详细源码可以参考:CaptureTouchView.java
为什么 DOWN 事件特殊所有 touch 事件都是从 DOWN 事件开始的,这是 DOWN 事件比较特殊的原因之一。另一个原因是 DOWN 事件的处理结果会直接影响后续 MOVE、UP 事件的逻辑。
在步骤 2 中,只有 DOWN 事件会传递给子 View 进行捕获判断,一旦子 View 捕获成功,后续的 MOVE 和 UP 事件是通过遍历 mFirstTouchTarget 链表,查找之前接受 ACTION_DOWN 的子 View,并将触摸事件分配给这些子 View。也就是说后续的 MOVE、UP 等事件的分发交给谁,取决于它们的起始事件 Down 是由谁捕获的。
mFirstTouchTarget 有什么作用mFirstTouchTarget 的部分源码如下:
可以看出其实 mFirstTouchTarget 是一个 TouchTarget 类型的链表结构。而这个 TouchTarget 的作用就是用来记录捕获了 DOWN 事件的 View,具体保存在上图中的 child 变量。可是为什么是链表类型的结构呢?因为 Android 设备是支持多指 *** 作的,每一个手指的 DOWN 事件都可以当做一个 TouchTarget 保存起来。在步骤 3 中判断如果 mFirstTouchTarget 不为 null,则再次将事件分发给相应的 TouchTarget。
容易被遗漏的 CANCEL 事件在上面的步骤 3 中,继续向子 View 分发事件的代码中,有一段比较有趣的逻辑:
上图红框中表明已经有子 View 捕获了 touch 事件,但是蓝色框中的 intercepted boolean 变量又是 true。这种情况下,事件主导权会重新回到父视图 ViewGroup 中,并传递给子 View 的分发事件中传入一个 cancelChild == true。
看一下 dispatchTransformedTouchEvent 方法的部分源码如下:
因为之前传入参数 cancel 为 true,并且 child 不为 null,最终这个事件会被包装为一个 ACTION_CANCEL 事件传给 child。
什么情况下会触发这段逻辑呢?
总结一下就是:当父视图的 onInterceptTouchEvent 先返回 false,然后在子 View 的 dispatchTouchEvent 中返回 true(表示子 View 捕获事件),关键步骤就是在接下来的 MOVE 的过程中,父视图的 onInterceptTouchEvent 又返回 true,intercepted 被重新置为 true,此时上述逻辑就会被触发,子控件就会收到 ACTION_CANCEL 的 touch 事件。
实际上有个很经典的例子可以用来演示这种情况:
当在 Scrollview 中添加自定义 View 时,ScrollView 默认在 DOWN 事件中并不会进行拦截,事件会被传递给 ScrollView 内的子控件。只有当手指进行滑动并到达一定的距离之后,onInterceptTouchEvent 方法返回 true,并触发 ScrollView 的滚动效果。当 ScrollView 进行滚动的瞬间,内部的子 View 会接收到一个 CANCEL 事件,并丢失touch焦点。
比如以下代码:
CaptureTouchView 是一个自定义的 View,其源码如下:
CaptureTouchView 的 onTouchEvent 返回 true,表示它会将接收到的 touch 事件进行捕获消费。
上述代码执行后,当手指点击屏幕时 DOWN 事件会被传递给 CaptureTouchView,手指滑动屏幕将 ScrollView 上下滚动,刚开始 MOVE 事件还是由 CaptureTouchView 来消费处理,但是当 ScrollView 开始滚动时,CaptureTouchView 会接收一个 CANCEL 事件,并不再接收后续的 touch 事件。具体打印 log 如下:
总结因此,我们平时自定义View时,尤其是有可能被ScrollView或者ViewPager嵌套使用的控件,不要遗漏对CANCEL事件的处理,否则有可能引起UI显示异常。
本课时重点分析了 dispatchTouchEvent 的事件的流程机制,这一过程主要分 3 部分:
-
判断是否需要拦截 —> 主要是根据 onInterceptTouchEvent 方法的返回值来决定是否拦截;
-
在 DOWN 事件中将 touch 事件分发给子 View —> 这一过程如果有子 View 捕获消费了 touch 事件,会对 mFirstTouchTarget 进行赋值;
-
最后一步,DOWN、MOVE、UP 事件都会根据 mFirstTouchTarget 是否为 null,决定是自己处理 touch 事件,还是再次分发给子 View。
然后介绍了整个事件分发中的几个特殊的点。
-
DOWN 事件的特殊之处:事件的起点;决定后续事件由谁来消费处理;
-
mFirstTouchTarget 的作用:记录捕获消费 touch 事件的 View,是一个链表结构;
-
CANCEL 事件的触发场景:当父视图先不拦截,然后在 MOVE 事件中重新拦截,此时子 View 会接收到一个 CANCEL 事件。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)