负一屏和桌面交互实现原理

5,851 阅读7分钟

负一屏,也就是在桌面右滑进入到的页面,如下 gif 图,google 桌面右滑进入到 google feed 页面。这个页面是一个独立应用,这次文章主要说明这两个应用之间的滑动切入的实现原理。

1. 跨进程通信原理简介

两个应用之间的交互,势必涉及到跨进程通信,在说明负一屏和桌面的交互实现原理前,有必要先介绍下跨进程通信的原理,也就是 Binder 的通信过程,由于这不是本次重点,这里只是简单说明。

如上图,Binder 通信是一个典型的 CS 模型,这里主要有三个角色: Client 进程、Server 进程已经进程间通信的 Binder 对象;

  1. Server 进程对外提供服务 Service;
  2. 当 Client 进程通过 bindService() 方式启动此服务时,Service 会返回一个 Binder 对象给 Client 进程;
  3. Client 拿到此 Binder 对象后,就能用于主动和 Server 进程通信;
  4. 同样 Client 进程能向 Server 进程注册一个 Binder 对象,用于 Server 进程主动和 Client 进程通信;
  5. 最后 Client 和 Server两者之前就互相持有对方的Binder对象,就能达到双向通信的目的。

2. Google Feed 屏方案

2.1 接口定义

基于 Binder 的跨进程通信原理,各家产商可能定义出不同的接口,具体的实现细节千差万别,这里基于Google原生的Feed屏方案来讲解。

前面说明 Binder 进程通信过程中,有两个 Binder 对象,谷歌对此提供了以下两个aidl接口:

// com.google.android.libraries.launcherclient
interface ILauncherOverlay {
    oneway void startScroll(); // Launcher 主动开始滑动,调此方法通知 Overlay
    oneway void onScroll(in float progress); // Launcher 滑动的进度
    oneway void endScroll(); // Launcher 停止滑动,调此方法通知 Overlay
    oneway void windowAttached(in LayoutParams lp, in ILauncherOverlayCallback cb, in int flags);
    oneway void windowDetached(in boolean isChangingConfigurations);
    oneway void closeOverlay(in int flags);
    oneway void onPause();
    oneway void onResume();
    oneway void openOverlay(in int flags);
    oneway void requestVoiceDetection(in boolean start);
    String getVoiceSearchLanguage();
    boolean isVoiceDetectionRunning();
    boolean hasOverlayContent();
    oneway void windowAttached2(in Bundle bundle, in ILauncherOverlayCallback cb);
    oneway void unusedMethod();
    oneway void setActivityState(in int flags);
    boolean startSearch(in byte[] data, in Bundle bundle);
}

interface ILauncherOverlayCallback {
    oneway void overlayScrollChanged(float progress); // Overlay 主动滑动的进度,Overlay 通过此接口回调给 Launcher
    oneway void overlayStatusChanged(int status); // Overlay 的滑动状态,比如开始滑动等,Overlay 通过此接口回调给 Launcher
}

ILauncherOverlay,故名思议,就是覆盖在桌面上的一层,这里代表的是负一屏,而ILauncherOverlayCallback就是负一屏的相关回调。

以 Launcher 为视角来看,这里可以看成一个CS模式,Launcher 为 Client,负一屏为 Server;桌面向负一屏bindservice,会获得 IlauncherOverlay 对象,通过这个对象的相关接口startScoll()、onScroll()、endScroll()通知负一屏滑动,而onScroll(float progress)方法中的参数可以通知负一屏显示的百分比,可以配合桌面显示,从而达到负一屏滑出的效果。

2.2 负一屏能在桌面显示的原理

负一屏和桌面毕竟是在两个进程里,负一屏是如何在桌面里显示出页面的呢?这里涉及到了Window的相关知识。

这里引用了网上的一张图来简单说明: 日常开发一个 Activity 页面的时候,都是通过各类的 View 来实现,而这些V iew 都在应用进程里,对应到系统进程里(WMS),只是在一个 Window 里。Window 是有分层,多个 Window 之间有 Z 轴顺序的,WindowManagerSeervice 就是来管理这些 Window 的,处于上层的 Window 会覆盖下层的 Window,而后由 SurfaceFlinger 负责绘制。

window有三种类型:应用window、子window、系统window,每种类型的层级都是不同,层级越大,所处的位置越顶层。

Window层级
应用Window1~99
子Window1000~1999
系统Window2000~2999

Window 的层级可以通过 WindowManager$LayoutParams#type 参数来设置,而负一屏的实现原理就是在桌面 Window 之上创建一个层级更高的 Window, 来显示负一屏的内容。

2.3 代码实现片段

桌面通过调用 ILauncherOverlay#windowAttached() 时,会传递一个 WindowManager.LayoutParams 对象,Launcher 将自己 Window 相关的 LayoutParams 传递给了负一屏,大概逻辑如下:

// Launcher.java
public void onAttachedToWindow() {
    mOverlay.windowAttached(
        mActivity.getWindow().getAttributes(), 
        mOverlayCallback, 
        mFlags);
}
// 负一屏
WindowManager.LayoutParams mLayoutParams;

public void windowAttached(WindowManager.LayoutParams lp, ILauncherOverlayCallback cb, int flags) {
    mLayoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
    mLayoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT;
    mLayoutParams.gravity = Gravity.START;
    // 负一屏的 Window 层级比 Launcher 的大就可以
    mLayoutParams.type = lp.type + 1; 
    mLayoutParams.token = lp.token;
    mLayoutParams.flags = WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS | 
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | 
        WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS | 
        WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS |
        WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION;
    mLayoutParams.x = -screenWidth;
    mLayoutParams.format = PixelFormat.TRANSLUCENT;
    
    mWindowManager.addView(mOverlayDecorView, mLayoutParams);
    
    if (cb != null) {
        cb.overlayStatusChanged(FLAG_SUCCESS);
    }
}

2.4 Window 结果验证

如上图,我们打印此桌面的 Window 层级如下:

$ adb shell dumpsys window w | grep -e "Window #" -e "mOwnerUid"
  Window #0 Window{2fe89fd u0 NavigationBar}:
    mOwnerUid=10028 mShowToOwnerOnly=false package=com.android.systemui appop=NONE
  Window #1 Window{ee37895 u0 StatusBar}:
    mOwnerUid=10028 mShowToOwnerOnly=false package=com.android.systemui appop=NONE
  Window #2 Window{bc26972 u0 AssistPreviewPanel}:
    mOwnerUid=10028 mShowToOwnerOnly=true package=com.android.systemui appop=NONE
  Window #3 Window{484bff7 u0 DockedStackDivider}:
    mOwnerUid=10028 mShowToOwnerOnly=false package=com.android.systemui appop=NONE
  Window #4 Window{a6fb5f6 u0 com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}:
    mOwnerUid=10038 mShowToOwnerOnly=true package=com.google.android.apps.nexuslauncher appop=NONE
  Window #5 Window{11fd688 u0 com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}:
    mOwnerUid=10040 mShowToOwnerOnly=true package=com.google.android.apps.nexuslauncher appop=NONE
  Window #6 Window{21768cd u0 com.android.systemui.ImageWallpaper}:
    mOwnerUid=10028 mShowToOwnerOnly=true package=com.android.systemui appop=NON

这里显示出了从上到下的window层级关系: #0 导航栏;
#1 状态栏;
#2 不知道是什么;
#3 好像是分屏相关的;
#4 负一屏所在的window;
#5 launcher window;
#6 壁纸所在window。

这里的#4和#5显示都是 NexusLauncherActivity, 可以通过 uid 来区分出是负一屏还是 Luancher 的 Window:

$ adb shell dumpsys package com.google.android.googlequicksearchbox | grep userId=
    userId=10038
$ adb shell dumpsys package com.google.android.apps.nexuslauncher | grep userId=
    userId=10040

2.5 负一屏页面滑动显示过程

由于负一屏 Window 处于 Launcher 上方,为避免在正常桌面显示情况下遮挡了桌面,负一屏 Window 的 LayoutParms.x 需要设为 -screenWidth,也就是负一屏 Window 处于桌面 Window的 左上方,如下图:

在开始滑动显示负一屏的时候,负一屏 Window 的 LayoutParms.x 设为 0,直接覆盖在桌面 Window 上方,但负一屏 Window 中 View 的 scrollX 一开始需要设为 screenWidth,即内容不可见,随着桌面的滑动,改变此 View 的 scrollX 参数使得负一屏的内容逐步显示出来,如下图:

3. 另外一种方案 -- 反射

3.1 反射方案说明

反射方案,指的是负一屏提供一个固定的类、固定的接口给 Launcher,如下方代码:

public class AssistantCtrl {
    public View createView() {
        // ... ...
    }
}

此接口会返回负一屏的页面 View, Launcher 通过反射获取到类 AssistantCtrl 对象,然后反射调用 createView() 方法获取到负一屏页面 View,并添加到自己的 View 里,如下代码:

Context foreignContext = context.createPackageContext("负一屏应用包名", flag);
Class cls = foreignContext.getClassLoader().loadClass("AssistantCtrl 类全路径名");
// ... 反射实现,略 ...

两者之间的交互关系如下图:

Launcher 通过反射获取到负一屏 View 的实例,所以负一屏 View 页面是运行在 Launcher 进程里,而 View 中的数据需要从负一屏获取,所以这里也需要做一层跨进程通信,同样也是用到 Binder 实现。

3.2 一个应用反射另一个应用相关类原理简介

这里涉及到了类的加载机制,这里做个简单介绍。

类的加载是通过 ClassLoader 来实现的,Android 中用到的是 BaseDexClassLoader.java

// BaseDexClassLoader.java
private final DexPathList pathList;

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
        String librarySearchPath, ClassLoader parent, boolean isTrusted) {
    // dexPath 为 dex 文件所在路径    
    this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    Class c = pathList.findClass(name, suppressedExceptions);
}
// DexPathList
private Element[] dexElements;

DexPathList(ClassLoader definingContext, String dexPath,
        String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
    // save dexPath for BaseDexClassLoader
    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext, isTrusted);
}

public Class<?> findClass(String name, List<Throwable> suppressed) {
    for (Element element : dexElements) {
        Class<?> clazz = element.findClass(name, definingContext, suppressed);
        if (clazz != null) {
            return clazz;
        }
    }
    return null;
}

总的来说,ClassLoader 根据字节码文件 dex 所在的文件路径去加载对象的 Class 对象,也就是说只要知道了类对应所在 dex 文件路径,通过 ClassLoader 就能去加载其 Class 对象。

关于这点,自己可以去自定义 ClassLoader 去验证,也可以通过查看前面提到的 Launcher 反射获取负一屏对象的源码来证明这一点,这里就不做详述。

4. 两种方案优缺点对比

Google Feed 屏方案
优点:负一屏和 Luancher 的解耦;负一屏页面是加载在负一屏进程。
缺点:Launcher 和负一屏之前的滑动切换不好处理,在滑动过程不断通过跨进程通信来达到两者间的滑动同步,没有反射方案那么流畅。

反射方案
优点:负一屏的 View 直接加载在 Launcher 进程里,所以 Launcher 和负一屏页面间的切换流畅易实现。
缺点:负一屏的 View 直接加载在 Launcher 里,占用 Launcher 的内存;负一屏和 Launcher 耦合度高,不便于拓展;需要 Launcher 提供其他的能力,如权限,比如负一屏的图片来源于网络,这就是要桌面有请求网络的能力。