稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

理解安卓的视图体系结构

当我们想要写一个页面的时候,通过一个Activity,然后调用其setContentView方法,把一个布局文件当作一个参数传递过去,然后一个页面就好了,但是除此之外,我们还需要与一些组件打交道,比如像Window,WindowManager,那么这些东西到底 与我们的页面布局有什么关系,今天就来学习一下,以便对整体窗口有个更清楚的认知。

布局是一颗View tree

先从一个最简单的例子出发,平时我们写一个页面,都从一个布局文件出发。这其实是在构建一个View tree,为啥一定是tree呢,因为我们的布局文件,无论有多么的复杂,都是从一个根(通常是一个ViewGroup对象)开始的,父布局里面再写子布局,比如这样的:

1
2
3
4
<LinearLayout id="app_root">
  <TextView id="label"/>
  <Button id="submit"/>
</LinearLayout>

这会形成一个树状结构:

| app_root
   |- label
   |- submit
作为一个开发者,写布局是我们再熟悉不过的了,主要就是用所熟悉的各种Layout和View一起来构建想要的页面。

所写的布局,最终会生成一颗View tree,是一个树状的数据结构,每一个节点都是一个View对象(ViewGroup和View)。因此,布局优化的一个是感觉重要的点就是要先减少View tree的深度(也即平时所说的减少布局的嵌套),再想办法减少广度(减少个数)。

那么,我们写的布局的父布局又是哪里呢?这就又涉及两个东西,一个叫做decorView和contentView的东西。

DecorView与ContentView

我们平常所见的屏幕窗口的根布局是一个叫做DecorView的东西,它是我们通常意义上整个屏幕的根节点,它包含了上面的Status bar和下方的Navigation bar,以及属于应用程序的中间部分。它的源码路径是frameworks/base/core/java/com/android/internal/policy/DecorView.java。它是一个真实的view,它是FrameLayout的子类。

它下面有一个id为android.R.id.content的FrameLayout,我们平时在Activity中调用setContent时所传过去的布局文件所生成的View tree都是添加在这个FrameLayout下面,所以,通常对于我们一个Activity来说,这个FrameLayout是直接意义上的根节点,我们所写的布局都是添加它下面的。

ContentView所引申出来的奇技淫巧

布局优化技巧

首先,一个是布局的优化技巧,可以减少View tree的层级:假如你写的布局中根节点也是一个FrameLayout,那么可以直接用merge节点,把子view全部都直接加挂到前面提到的系统创建的Activity的根布局上面。

1
2
3
4
<merge>
  <Text />
  <Button />
</merge>

这可以把View tree减少一个层级(深度减1)。

页面内即插即用的弹窗

每个Activity都被回挂在一个id是android.R.id.content的FrameLayout下面,利用这一点,可以做一些即插即用的弹窗,即插即用的意思是,不用写在布局里面,而且显示的时间是不固定的,可能很多时候都不显示,在某个特定的逻辑或者时间才显示。就好比某些电商特定节日的弹窗一样,这种东西,一年也显示不了几回,如果直接添加在布局里面(哪怕你用ViewStub),不够优雅,毕竟不是常规逻辑下会出现的页面,这时可以利用content来做一些即时弹窗:

1
2
3
FrameLayout container = activity.findViewById(android.R.id.content);
View pop = <create or inflate your own view>;
container.addView(pop);

只要你能获得到Activity的实例(这个并不难),那么就可以非常优雅的添加弹窗,逻辑代码和布局文件都会相当独立,甚至可以用插件形式来异步加载。再进一步,如果 添加一个WebView,那么就可以做得更加的前端化,实时化和定制化,好多电商的弹窗就是这么干的。

Window与WindowManager

作为应用开发者,我们看一个View tree其实就是一坨布局,这是站在一个非常小的角度去看的,但如果站在整体系统架构角度来看的话,就会发现应用程序所在的view tree仅是系统可视化窗口架构中的末端,View只是用来构建视图的基本砖块而已。对于整体View tree是如何渲染的,何时渲染,这就涉及到了整体系统架构层面的重量级组件了。

对于现化代的视图窗口架构(Modern GUI),都有一个window server,作来管理视图窗口的核心组件,比如X11,Android当中也不例外。在Android里面,WindowManager就是专门用于管理视图窗口的,它是系统级别的server叫window manager server是一个系统级别的常驻进程,由init.rc启动。而Window则是一个基本的窗口的逻辑上的抽象。关于Window以及WindowManager本身就是相当大的话题,都可以单独写本书,这时不做过多的探讨,对于我们应用开发者来说,了解一下基本的知识就够用了。

每一个Activity,都有一个Window对象,所有一切与GUI有关的事情,都委派给了Window对象,Actvity本身并不参与GUI的具体流程,比如像上面提到的DecorView,ContentView等View tree的构建与管理,View tree的渲染,以及像事件的处理,都是Window对象处理的。Window是WindowManager的基本对象,与其server之间通过IPC通信,Window是供应用程序端使用的,其实真正一切都掌握在window server手中。Activity和Dialog使用的对象都是PhoneWindow,它在frameworks/base/core/java/com/android/internal/policy/PhoneWindow.java,Window对象会具体负责创建像DecorView之类的一些基础设施。最为关键的一个方法就是其PhoneWindow#installDecor()方法,这个方法里面会先调用generateDecor()创建mDecor,它就是前面讲到的DecorView对象,再通过generateLayout()创建mContentParent对象,它就是前面讲到的id是android.R.id.content的那个FrameLayout,Activity或者Dialog通过setContentView送过来的View tree就是加在它的下面的。

WindowManager是一个接口(Android系统的代码接口用的特别多,很多关键的架构层面的组件 都是接口,实际使用的都是其一个实现。)实际使用的是WindowManagerImpl对象,而它也没干啥,它把事情 又委派给另外一个叫做WindowManagerGlobal的对象,这个WindowManagerGlobal则是GUI端的最后一站,它负责与wms(WindowManagerServer)通信。它在frameworks/base/core/java/android/view/WindowManagerGlobal.java

需要注意WindowManagerGlobal是一个单例,也就是说每一个应用程序(严格来说是每一个进程只有一个实例,但安卓上面带有GUI的应用程序只能存活在一个进程,所以可以理解 为一个应用程序)只有一个实例,所以它管理着一个应用程序中的所有的View tree。从它的成员中便可看出,它有一坨ViewRootImpl对象(一个列表),而每一个ViewRoot对象管理着一颗View tree。

最为关键的一个方法就是WindowManagerGlobal#addView,每一个Window的持有者对象(如Activity或者Dialog)都是通过这个方法将其DecorView对象添加给WindowManager的。addView方法,会先创建一个ViewRootImpl对象,然后把要添加的view以及刚创建出来的ViewRootImpl都放进它的列表中,最后再调用ViewRootImpl#setView(view),这就把几大关键对象建立好了连接,接下来的事情就归ViewRootImpl了。这里还有一个相当关键的对象,那就是LayoutParams,WindowManagerGlobal也有一个列表里面存着每个Viewtree根节点(也就是Decor view)的LayoutParams。

ViewRootImpl又是个啥

Window是从手机系统角度来看待的窗口的概念,而View tree则是从应用程序角度构建GUI页面的概念,view tree是Window的一部分,Window对象持有mView,而这个mView就是上面提到的DecorView,也即是View tree的根节点。这里又要涉及另外一个对象ViewRootImpl,它并非是View tree的一部分,虽然名字上比较容易混淆,因为它并不是View的子类,所以它不是任何一个View tree的节点,它的职责是管理View tree,像渲染以及事件派发,都是Window直接通过ViewRootImpl来进行的。在代码中实际使用的是ViewRootImpl对象,它实现了ViewParent接口。

所以,ViewRootImpl对象是值得细细研究的,因为实际上是它在管理着GUI系统–view tree的管理,渲染的三大步(measure, layout和draw)以及事件的派发,最源头的逻辑都在这个对象里面,当然 它也是非常复杂的,源码大概有1万行左右。

ViewParent又是个啥

它是一个接口,行使的职责是管理子View,也就是说在View tree当中管理子View的行为的集合便是ViewParent接口。View tree的节点都是View的子类,所以,你看ViewRootImpl实现了ViewParent接口,它是负责管理Window里面的View tree的。另外一个就是ViewGroup,ViewGroup是View的子类,所以它是Viewtree的一部分,父节点都是ViewGroup,它核心就两样东西一个是子View的列表,另外就是ViewGroup也实现了ViewParent的接口,因为它也要管理它的子节点(也即子View)。

Activity到底是个啥东西

它是系统的四大核心组件之一,如果想构建GUI页面,则Activity是绕不开的。如果再详细一点,Activity是一个系统给你的融合了应用生命周期管理,组件级别复用(Intent相关)和窗口管理的组件,生命周期也即ActivityManager干的事情,它通过Activity的回调告诉你;而GUI则是通过Activity的Window对象帮你实现(Activity的布局和事件的处理都是委派给其持有的Window对象来处理)。

如果,把Activity的Window对象拿掉,那么它跟一个Service组件就基本上没有差别了。如果把Activity的Intent相关拿掉,那么它跟一个Dialog就没啥区别了。

Fragment又是个啥

坦白说,Fragment是Google挖的一个大坑,这玩意儿不符合Android的核心设计思想,因为Android出世的时候并没有它,是后来Google跟水果平台抄来的一个不伦不类的东西,结果全是坑。在它刚出来的一些年,Google极力的推荐使用Fragment,但是近一两年,又不推荐了。

Fragment本质上就是一个强加了生命周期函数回调的View,因为显示Fragmeng时,都是把它替换一个View或者添加到一个ViewGroup上面,所以它就是一个View,或者说一个View tree中的节点。但是强加了生命周期的回调。光是这两点,其实也没有啥,毕竟生命周期对于View是重要的,一般时候我们要在onResume与onPause之间才让View处于active状态。

Fragment最大的问题在于它的异步机制和状态恢复机制,也就是说用FragmentManager#commit了以后,具体啥时候Fragment会真正显示出来,我们是无法控制的,这是相当的坑;它的状态恢复机制就更加的坑,状态恢复这个东西如果全让程序员来负责也还好,就像Activity的设计一样,但是如果框架帮你做了一些事情,但又不完整,这就坑了,关于状态恢复的坑可以参考这篇文章来详细的了解。

DialogFragment

这个本质上是Dialog,但是被包了一层Fragment,所以它会有Fragment的特性,但是Window和View tree则是属于Dialog的。

注意:FragmentTransaction#add(Fragment fragment, String tag)有一个方法是不需要提供父布局,这是为没有常规布局准备的,因为无法把布局添加到Activity的现有View tree之中。一般情况下,我们是不会使用这个方法的,目前看仅在DialogFramgment中使用这个方法,那是因为Dialog本身有Window和view tree。

不在Activity view tree里面的窗口控件

一般来讲,我们想要显示的页面都会放进布局里面,也就是说大部分时候我们的页面都由Activity的view tree来实现。但是有些特殊的场景,却不是在view tree里面,比如弹窗,像Dialog,PopupWindow以及Toast,这些东西一般是用于弹出式的页面,由特定的逻辑触发,它与常规页面最为显著的区别就是,它们与Activity的Window和View tree是独立开来的,它们并不是添加在当前Activity的view tree上面的。它们自己有独立的view tree,或者换句话说,它们是独立的Window。

我们这里重点探讨它们与Window和当前Activity之间的关系,至于它们的基本使用方法,可以参阅其他文章。

Dialog

这里不说基本使用方法。

通过查阅源码,可以发现Dialog与Activity的实现相当类似,它内部也有一个独立的Window,也是通过WindowManager#addView把其ContentView(我们提供的布局)加到屏幕上去的。因此,它与Activity也是相互独立的,是两个Window,两棵View tree。Dialog类里面还有getActionBar,OptionsMenu等相关的方法,但似乎在实际使用当中比较少用到。

Dialog最为核心的两个方法一个是其构造方法,这其中会创建Window对象,另外一个就是#show,里面可以看到,它是通过WindowManager#addView()方法,来把它的mDecorView添加到窗口体系当中的,这与Activity其实是一样的。

为啥显示Dialog一定需要Activity,一般Context却不可以

使用过Dialog的人都知道,创建Dialog时一定要传递Activity为其参数,尽管构造方法里面声明的是Context。前面提到,Dialog有自己的Window和View tree,理论上它跟Activity是没有关系的。

如果,用一个非Activity作为Context传给Dialog,报错,是WindowManager抛出来的异常,说:

1
2
3
4
5
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
        at android.view.ViewRootImpl.setView(ViewRootImpl.java:1093)
        at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:409)
        at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:110)
        at android.app.Dialog.show(Dialog.java:342)

而Dialog#setOwnerActivity(Activity)方法在创建Dialog之后再把相关Activity塞过去,也是不行的,必须传入的Context参数要是一个Activity实例才可以。

最初以为,可以从它的构造方法中看出为啥一定需要Activity,就是因为需要theme.但其实并不是,因为theme是可以通过resource id传进去的。

关键点仍在于ViewRootImpl对象,因为这个异常是ViewRootImpl在其setView方法中抛出的,前面讲过,向一个Window添加布局最终会走到WindowManagerGlobal#addView,而它又是通过ViewRootImpl#setView来做具体事情 的,这个方法里面,会先获取当前的WindowSession,然后再把当前的Viewtree转化为窗口对象,添加给wms。所以最核心的地方还得看WindowManagerService#addWindow()这个方法,这个方法也相当之复杂,但是还是能大概看懂它的意思。

通俗的来理解这是安卓系统本身加的限制,也就是说窗口本身也是有逻辑关系的,可以简单理解 为树状关系,一个Activity是主Window,而由此Activity衍生出来的属于此Window的子Window,因此在添加子Window的时候,必须 要知道它从属于哪个父Window,因此,你必须 传Activity实例给Dialog的构造方法,因为只有Activity才是有主Window的。但是这个具体的逻辑连接却比较奇怪,从上面的过程描述来看,WindowManager#addView到ViewRootImpl#setView,其实,都没有明确的把父Window,也就是Activity的Window传进来,那么WindowManagerService又是从哪里去找这个父Window呢?

WindowManagerService#addWindow方法,并没有传递父Window参数 进来,那就只能是它从传进来的参数获得的。这里一个很重要的东西就是token,它是一个IBinder对象,它是一个Window的标识,它存在Window的attris对象里面,这个就是WindowManager#LayoutParams对象,它的作用就是存储Window的特征参数,比如你要改变Window的一些特性(通俗来说就是定制一下Window),那么通过改变LayoutParams,就可以了。这个其实不难理解,我们对View不就是通过其LayoutParams来改变View的特征参数 么。都 是一样的。

Dialog对象在show()时会把其mDecor添加到WindowManagerService中去,其并未传父Window,只传了一个LayoutParams过去,其实玄机也就在LayoutParams之中,窗口的token,父token(标识着父窗口)以及像窗口的type都是在LayoutParams中。那么这个LayoutParams是哪里创建的呢?它是来自于Window对象的,而Dialog的mWindow成员实例是在构造时创建的,创建的是一个PhoneWindow对象,并且把构造Dialog传进来的Context对象传给了PhoneWindow的对象,LayoutParams对象则是通过mWindow.getAttributes()得来的。因此啊,可以断定,PhoneWindow在生成LayoutParams时,会从传给其构造的上下文对象mContext中获取一些信息,如窗口的类型或者父窗口信息,而只有Activity对象才有窗口信息,并且可以作为父窗口,而普通 的Context对象是没有窗口的,由此可以解答我们的疑惑了。

也可以显示独立于任何Activity的Dialog

窗口是有很类型的,WindowManagerService为了方便管理,所以针对Activity及其从属于子窗口(Dialog和PopWindow)做了类似tree结构的逻辑上的整理,所以普通 的Dialog必须要能找到其主窗口(或者叫父窗口)。

但其实,我们经常能见到一些非常牛逼的Dialog,可以显示 在任何Activity之上,如电源没了,或者音量调节,等等。这些是叫作system dialog,需要特殊权限 才能显示出来的。管理来理解,系统级别的组件 才有权限 显示system dialog。

其实,想一想也合理,作为一个应用程序,你在自己的生命周期内,显示内容给用户足够的信息就可以了。当用户离开了你的应用,你也没有必要再显示Dialog了。

:应用在后台时,想在前台显示信息有其他的方式,如Notification等,这属于另外的话题,不做过多讨论。

可以弄个全屏的Dialog吗?

一般来讲呢,Actiivty都是全屏的,Dialog一般是非全屏的,可以把一个Activity弄成非全屏的,长的像Dialog一样,当成Dialog来使用,就在设置Activity的Theme时,用Theme.Dialog就可以了。

那么,反过来搞可不可呢,就是可不可以把常规的Dialog弄成一个全屏的呢?

从Dialog的实现上来看,它有Window对象,甚至连Actionbar和OptionsMenu都有,所以从实现上来看,Dialog并不一定非要像我们平常所使用的那样是一个对话框,它能做的事情 不比Activity少。默认Dialog的style就是一个平常的对话框,但其实,设置不同的style,就可以得到全屏的dialog。

1
2
3
4
5
6
7
8
9
 private void showFullscreenDialog() {
    // Theme_Material_NoActionBar_Fullscreen is real full screen, i.e. hide the status bar.
    Dialog dialog = new Dialog(this, android.R.style.Theme_Material_NoActionBar);
    dialog.setContentView(R.layout.fullscreen_dialog);
    dialog.findViewById(R.id.okay).setOnClickListener(view -> {
        dialog.dismiss();
    });
    dialog.show();
}

:这里有点歧义,全屏意思是指铺满整个父Activity,严格意义上的全屏是要把状态栏也要隐藏掉。

PopupWindow

PopupWindow是一个独立的类,并不是View的子类,因此,它跟常规的widget不一样,无法直接添加到现有的View tree之中,这也导致它的实现方式比较复杂。

PopupWindow它并没有创建Window对象,但是它有一个类似于Window对象的DecorView的东西,它的根节点是一个叫做PopupDectorView的东西,其实是一个FrameLayout,我们让PopupWindow显示的布局就是加在这个PopupDectorView下面。最重要的两个方法一个是preparePopup() 这个方法会创建根节点PopupDecorView,然后把我们需要显示的mContentView以及还有一个PopupBackgroundView(也是一个FrameLayout,包裹在要显示的ContentView外面),放在PopupDecorView的下面,所以真实的结构是根节点是PoupDecorView,包了PopupBackgroundView,再包上要显示的mContentView,一共三层。

另外,一个方法就是invokePopup,核心逻辑是调用WindowManager#addView,把mDecorView添加到窗口系统中以显示出来,后面的过程跟上面提到的Dialog的显示过程是一样的。那么PopupWindow又是如何找到Activity的主Window的呢?答案还是在LayoutParams中,方法preparePopup()的参数 是LayoutParams,如前面所述LayoutParams是最终会传递给WindowManagerService的,而这里面就包含了主窗口的信息。而这个LayoutParams对象是通过方法createPopupLayoutParams()得来的,而这个方法的参数 是一个IBinder对象,我们知道这个IBinder对象就标识着一个主窗口。那么PopupWindow的IBinder对象又从何而来呢?是通过View.getWindowToken()得来的,PopupWindow的显示 方法都要提供一个View如showAsDropDown,里面的参数是一个View,而这个View必须 是已显示的View tree中的一个节点,现在应该知道一个窗口有一颗View tree,那么此View tree中的节点肯定 知道自己属于哪个窗口啊,由此便找到了主窗口。

另注意,PopupMenu,也是基于PopupWindow的,只不过弄成了Menu的样子(其实就是一个ListView)。

可以弄个全屏的PopupWindow吗?

当然 可以,只需要在构造PopupWindow时传入MATCH_PARENT作为其宽和高就可以了,不过这样做以后后面再选择哪种show方式就不影响了,都是铺满Activity来显示。

1
2
3
4
5
6
7
8
9
10
11
 private void showFullscreenPopup() {
    final View content = LayoutInflater.from(this).inflate(R.layout.fullscreen_dialog, null, false);
    PopupWindow popup = new PopupWindow(content, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
    final View anchor = findViewById(R.id.fullscreen_popup);
    // Key is the width and height passed to constructor, show does not affect anything.
//        popup.showAtLocation(anchor, Gravity.NO_GRAVITY, 0, 0);
    popup.showAsDropDown(anchor, 100, 200);
    content.findViewById(R.id.okay).setOnClickListener(view -> {
        popup.dismiss();
    });
}

Toast又是个啥

这个大家都非常熟悉了,每天都用到,用以给出一些非常弱的提示。

它其实也是有独立Window的。Toast类本身比较简单,但它也是有一个专门的Server的叫NotificationManager,Toast也是一个客户端,直接做工作的是另一端的服务,这也是为何即使我们的应用退到了后台依然可以show一个Toast。我们用的最多的就是让其显示一段文字,但其实那只是它的一个非常基础的用法。从Toast的方法就可以看出来,它是可以接受一个View的,所以把一个布局的根节点传进去,那这个布局不就可以显示了么?

Toast可以显示复杂布局吗?

虽然,通常我们都是使用Toast.makeText方法,但这并意味着它只能显示纯文字,它是可发接收一个View作为其Content的,就通过其setView方法:

1
2
3
4
5
6
7
8
9
10
11
 private void showComplexToast() {
    Toast toast = new Toast(this);
    final View dialog = LayoutInflater.from(this).inflate(R.layout.fullscreen_dialog, null, false);
    toast.setDuration(Toast.LENGTH_LONG);
    toast.setView(dialog);
    // This does not work, Toast cannot receive focus, i.e. it won't receive events from WMS
    dialog.findViewById(R.id.okay).setOnClickListener(view -> {
        toast.cancel();
    });
    toast.show();
}

不过呢,虽然Toast可以展示更为复杂的布局,但是它是无法接收用户事件,也就是说它是无法处理点击事件的,你想有用户交互的话,是不可以的。

如此,假如你想显示一个类似Toast的,但是可以交互 的,那只能用PopupWindow或者Dialog来模拟,但这又只能是在应用在前台时显示;假如在后台时,又想要有交互行为,那只能用Notification和PendingIntent了。

综合结论

说了这么多,希望还没有看晕,总结一下:

  1. Window也是有结构 关系的,类似于View一样,像一样tree
  2. 每一个Window都有一颗View tree,DecorView是其根节点
  3. ViewRootImpl是用来管理View tree的
  4. Dialog和PopupWindow可以用以显示铺满Activity,甚至全屏的View
  5. Toast也可以展示复杂布局

实战建议

Activity应该只用于显示一个页面内的主要的,逻辑上都可以触达的布局,比如一上来用户就可见的所有东西,以及常规操作可以触发的(如折叠展开等)。

Activity的View tree要尽可能的小,这样才能保证最好的渲染性能,其余的,很多一次性的,即插即用的,鲜有逻辑才会有触发的,这种布局,要尽可能的独立于Activity的View tree之外,以保证其布局和逻辑上的独立,也更方便维护,更能减少Activity的view tree的体积。因为Dialog和PopupWindow也可以铺满整个Activity,所以,像一些用户引导,新人引导,运营活动,分享,等等一些常规逻辑走不到的页面,都可以考虑用Dialog和PopupWindow来实现。

Comments