Android分屏功能原理(基于Android12L)

Android分屏功能原理(基于Android12L)

分屏功能目的是为了提高用户的生产效率,提高多应用使用的便捷性;Android 很早版本就已经提供了分屏功能,不过随着版本的迭代,特别是Google开始关注Android大屏设备的用户使用体验,内部的实现逻辑也和以前有很大的差别

先来看看原生分屏的使用

值得注意的是原生Android分屏功能只允许在任务管理器中选择分屏应用,如果应用未打开过,就无法分屏

Android13上这一点有差别,Launcher支持图标长按触发分屏功能,不过分屏的另一个应用也只能在任务管理器中起。

本篇文章简单解析一下整体方案的架构,了解各个模块做了哪些事情,这里分4个阶段解析,初始化阶段,触发分屏阶段,分屏拉伸阶段,退出分屏阶段

初始化阶段

SystemUI在进程初始化阶段就已经准备好分屏所需要的 MainStage 和 SideStage 对象 ,这两个对象很重要,分别负责分屏的一边,对象内部会创建一个 RootTask 节点了(这里利用了WindowOrganizer框架的能力,如果有想了解后续单独写一篇文档),这个RootTask就是分屏的关键,通过把应用的Task节点挂载到RootTask下,然后修改RootTask节点的Bounds来改变应用显示的大小。

scss 复制代码
//frameworks/base/libs/WindowManager/Shell/src/com/android/wm/shell/ShellInitImpl.javaShellInitImpl.java
private void init() {
    // 会为分屏功能分别创建 MainStage 和 SideStage (内部又会创建一个RootTask节点)
    mSplitScreenOptional.ifPresent(SplitScreenController::onOrganizerRegistered);
}

// StageTaskListener.java
StageTaskListener(...) {
    taskOrganizer.createRootTask(displayId, WINDOWING_MODE_MULTI_WINDOW, this);
}

触发分屏阶段

任务管理器中触发分屏

任务管理器中点击分屏按钮后Launcher3 就会通知SystemUI触发分屏功能,真正分屏的之前的一系列悬浮动画,图标的分屏动画这些都是属于Launcher的业务逻辑,细节不做阐述。

以下阶段都是属于Launcher内部逻辑

这里有个点,Launcher3是如何和SystemUI建立通信的呢?

SystemUI(OverviewProxyService.java)里通过bind Launcher3的TouchInteractionService来与Launcher3建立连接,然后Launcher3就持有了ISystemUiProxy 对象可以与SystemUI交互

Launcher3通过mSystemUiProxy.startTasksWithLegacyTransition方法通知 SystemUI 触发分屏功能,细节代码如下:

java 复制代码
// SplitSelectStateController.java
public void launchTasks(Task task1, Task task2, @StagePosition int stagePosition,
        Consumer<Boolean> callback, boolean freezeTaskList, float splitRatio) {
        
    if (TaskAnimationManager. ENABLE_SHELL_TRANSITIONS) {
        mSystemUiProxy.startTasks(taskIds[0], null /* mainOptions */ , taskIds[1],
                null /* sideOptions */ , STAGE_POSITION_BOTTOM_OR_RIGHT, splitRatio,
                new RemoteTransitionCompat(animationRunner, MAIN_EXECUTOR,
                        ActivityThread.currentActivityThread().getApplicationThread()));
    } else {
        RemoteSplitLaunchAnimationRunner animationRunner =
                new RemoteSplitLaunchAnimationRunner(task1, task2, callback);
        final RemoteAnimationAdapter adapter = new RemoteAnimationAdapter(
                RemoteAnimationAdapterCompat.wrapRemoteAnimationRunner(animationRunner),
                300, 150,
                ActivityThread.currentActivityThread().getApplicationThread());
    
        ActivityOptions mainOpts = ActivityOptions.makeBasic();
        if (freezeTaskList) {
            mainOpts.setFreezeRecentTasksReordering();
        }
         // 通知SystemUI启动分屏
        mSystemUiProxy.startTasksWithLegacyTransition(taskIds[0], mainOpts.toBundle(),
                taskIds[1], null /* sideOptions */ , STAGE_POSITION_BOTTOM_OR_RIGHT,
                splitRatio, adapter);
      }
}

这个ShellTransitions目前是Google的一个Feature还在开发中,默认是关闭的,这一套框架目前看是用来整合系统的动画的,包括转场动画、分屏动画等,现在我们暂时不涉及.

Android12L只支持任务管理器中的任务分屏,在Android13上,已经支持新启动的Activity方法是mSystemUiProxy.startIntentAndTaskWithLegacyTransition 方法,估计这就是从Launcher3上长按图标进入分屏功能所调用的方法了

紧接着 SystemUI 进程触发分屏操作

less 复制代码
//frameworks/base/libs/WindowManager/Shell/src/com/android/wm/shell/splitscreen/StageCoordinator.java
void startTasksWithLegacyTransition(int mainTaskId, @Nullable Bundle mainOptions,
        int sideTaskId, @Nullable Bundle sideOptions, @SplitPosition int sidePosition,
        float splitRatio, RemoteAnimationAdapter adapter) {
     // 显示分屏中间的View
    setDividerVisibility(true /* visible */ );
    final WindowContainerTransaction wct = new WindowContainerTransaction();
    
    mSplitLayout.setDivideRatio(splitRatio);
    if (mMainStage.isActive()) {
        mMainStage.moveToTop(getMainStageBounds(), wct);
    } else {
        // Build a request WCT that will launch both apps such that task 0 is on the main stage
     // while task 1 is on the side stage.
         // 设置mMainStage对应的RootTask的Bounds并移动到最前面
        mMainStage.activate(getMainStageBounds(), wct, false /* reparent */ );
    }
     // 设置mSideStage对应的RootTask的Bounds并移动到最前面
    mSideStage.moveToTop(getSideStageBounds(), wct);
    
     // 配置launch task的option,让分屏应用的task启动到RootTask节点之下
    // Make sure the launch options will put tasks in the corresponding split roots
    addActivityOptions(mainOptions, mMainStage);
    addActivityOptions(sideOptions, mSideStage);
     // 启动分屏应用,启动方式和从任务管理器启动是一样的,startActivityFromRecents
    // Add task launch requests
    wct.startTask(mainTaskId, mainOptions);
    wct.startTask(sideTaskId, sideOptions);
     // 所有修改封装到WindowContainerTransaction中然后通过WindowOrganizer框架完成上面的变化
    // Using legacy transitions, so we can't use blast sync since it conflicts.
    mTaskOrganizer.applyTransaction(wct);
}

核心做了以下操作

  • 显示分屏中间的View
  • 设置mMainStage对应的RootTask的Bounds并移动到最前面
  • 设置mSideStage对应的RootTask的Bounds并移动到最前面
  • 启动分屏应用,让分屏应用的task启动到RootTask节点之下,启动方式和从任务管理器启动是一样的,Framework侧对应的就是startActivityFromRecents方法

这里还是运用了WindowOrganizer 框架的能力,把所有修改点封装到 WindowContainerTransaction 中,然后通过mTaskOrganizer.applyTransaction(wct); 转交给Framework,Framework解析WindowContainerTransaction,然后执行对应的变化 我们可以看看WindowContainerTransaction的内容

Android12L上两个应用都得是从任务管理器中起 startActivityFromRecents

在 Android13上通过wct.sendPendingIntent(pendingIntent, fillInIntent, sideOptions) 支持新起一个应用

命令行触发分屏

arduino 复制代码
// SideStagePosition 0 代表左边, 1 代表右边
// taskId 可以通过adb shell am stack list 来查看应用对应的taskId

adb shell dumpsys activity service SystemUIService WMShell moveToSideStage <taskId> <SideStagePosition> 

命令行会把taskId对应的task挂载到SideStage 对应的RootTask下,然后SideStage监听到task变化,然后就会激活MainStage,然后申请分屏操作,这部分代码如下:

scss 复制代码
// StageCoordinator.java
private void onStageHasChildrenChanged(StageListenerImpl stageListener) {
    final boolean hasChildren = stageListener.mHasChildren;
    final boolean isSideStage = stageListener == mSideStageListener;
    if (!hasChildren) {
        if (isSideStage && mMainStageListener.mVisible) {
            // Exit to main stage if side stage no longer has children.
            exitSplitScreen(mMainStage, EXIT_REASON_APP_FINISHED);
        } else if (!isSideStage && mSideStageListener.mVisible) {
            // Exit to side stage if main stage no longer has children.
            exitSplitScreen(mSideStage, EXIT_REASON_APP_FINISHED);
        }
    } else if (isSideStage) {
            // SideStage对应的RootTask监听到task变化,然后就会触发分屏操作
            final  WindowContainerTransaction  wct  =  new  WindowContainerTransaction ();
            // Make sure the main stage is active. 
            // 这里的reparent是关键,为true后会把后台的Task作为分屏的一部分,如果没有后台task,不能触发分屏
            mMainStage.activate(getMainStageBounds(), wct, true   /* reparent */  );
            mSideStage.moveToTop(getSideStageBounds(), wct);
            mSyncQueue.queue(wct);
            mSyncQueue.runInSync(t -> updateSurfaceBounds(mSplitLayout, t));
        }
}

这里需要注意 mMainStage.activate(getMainStageBounds(), wct, true /* reparent */ ); 这里的reparent是关键,为true后会把后台的Task作为分屏的一部分,如果没有后台task,不能触发分屏,而且命令行分屏由于缺少了Launcher3的参与,缺少分屏之前的动画,效果上就是直接硬切的,不过这个命令行可以注意一下,不止这个功能,后续打开SystemUI进程的一些log也是支持的,需要留意一下

调用链如下:

分屏拉伸阶段

过程如下图所示

这部分逻辑主要集中在SystemUI进程, 中间的分割线是 DividerView.java ,这个不是窗口,而是利用 SurfaceControlViewHost + WindowlessWindowManager 的能力(这个有兴趣后续也可以另外补充,它支持跨进程显示View)将内容直接渲染到对应的SurfaceControl中,然后添加到SurfaceFlinger的layer tree中。

拉动过程中,左右两边的UI也属于SystemUI进程,上文也讲过,SystemUI进程初始化阶段会创建MainStageSideStage,它们分别会创建RootTask,不仅如此,拉伸的时候也会由这两个类处理

拉伸过程方法如下

scss 复制代码
//StageCoordinator.java
@Override
public void onLayoutSizeChanging(SplitLayout layout) {
    mSyncQueue.runInSync(t -> {
        updateSurfaceBounds(layout, t);
        mMainStage.onResizing(getMainStageBounds(), t); 
        mSideStage.onResizing(getSideStageBounds(), t); 
    });
}

MainStage和SideStage分别持有mSplitDecorManager对象,拉伸过程中的遮罩UI就是由这个类创建的

ini 复制代码
// SplitDecorManager.java
public void onResizing(ActivityManager.RunningTaskInfo resizingTask, Rect newBounds,
        SurfaceControl.Transaction t) {
    if (mResizingIconView == null) {
        return;
    }

    if (mBackgroundLeash == null) {
        mBackgroundLeash = SurfaceUtils.makeColorLayer(mHostLeash,
                RESIZING_BACKGROUND_SURFACE_NAME, mSurfaceSession);
        t.setColor(mBackgroundLeash, getResizingBackgroundColor(resizingTask))
                .setLayer(mBackgroundLeash, SPLIT_DIVIDER_LAYER - 1)
                .show(mBackgroundLeash);
    }

    if (mIcon == null && resizingTask.topActivityInfo != null) {
        // TODO: add fade-in animation.
mIcon = mIconProvider.getIcon(resizingTask.topActivityInfo);
        mResizingIconView.setImageDrawable(mIcon);
        mResizingIconView.setVisibility(View.VISIBLE);

        WindowManager.LayoutParams lp =
                (WindowManager.LayoutParams) mViewHost.getView().getLayoutParams();
        lp.width = mIcon.getIntrinsicWidth();
        lp.height = mIcon.getIntrinsicHeight();
        mViewHost.relayout(lp);
        t.show(mIconLeash).setLayer(mIconLeash, SPLIT_DIVIDER_LAYER);
    }

    t.setPosition(mIconLeash,
            newBounds.width() / 2 - mIcon.getIntrinsicWidth() / 2,
            newBounds.height() / 2 - mIcon.getIntrinsicWidth() / 2);
}

显示逻辑也和DividerView一样,利用了SurfaceControlViewHost + WindowlessWindowManager 的能力,把渲染好的SurfaceControl挂载到了对应Task的上面,如图所示

可以看到除了SplitDecorManager还有一个Dim layer,这个Dim layer也是MainStage和SideStage创建的,作用是当拖动到过于边缘的时候,会在一层dim用来提醒用户,效果如下图

退出分屏阶段

拉伸退出

这个阶段也是在SystemUI进程,拉伸退出分屏,先是会触发退出动画,当动画结束就会退出分屏,链路如下

退出代码核心逻辑在以下

less 复制代码
//StageCoordinator.java
private void applyExitSplitScreen(StageTaskListener childrenToTop,
        WindowContainerTransaction wct, @ExitReason int exitReason) {
    mRecentTasks.ifPresent(recentTasks -> {
        // Notify recents if we are exiting in a way that breaks the pair, and disable further
        // updates to splits in the recents until we enter split again
        if (shouldBreakPairedTaskInRecents(exitReason) && mShouldUpdateRecents) {
            recentTasks.removeSplitPair(mMainStage.getTopVisibleChildTaskId());
            recentTasks.removeSplitPair(mSideStage.getTopVisibleChildTaskId());
        }
    });
    mShouldUpdateRecents = false;

    // When the exit split-screen is caused by one of the task enters auto pip,
    // we want the tasks to be put to bottom instead of top, otherwise it will end up
    // a fullscreen plus a pinned task instead of pinned only at the end of the transition.
    final boolean fromEnteringPip = exitReason == EXIT_REASON_CHILD_TASK_ENTER_PIP;
    // 把应用Task还原到DefaultTaskDisplayArea节点下,同时把需要显示的应用Task放在最上面
    mSideStage.removeAllTasks(wct, !fromEnteringPip && childrenToTop == mSideStage);
    mMainStage.deactivate(wct, !fromEnteringPip && childrenToTop == mMainStage);
    // 使用WindowOrganizer框架实现上述变化
    mTaskOrganizer.applyTransaction(wct);
    mSyncQueue.runInSync(t -> t
            .setWindowCrop(mMainStage.mRootLeash, null)
            .setWindowCrop(mSideStage.mRootLeash, null));

    // Hide divider and reset its position.
    // 重置状态
    setDividerVisibility(false);
    mSplitLayout.resetDividerPosition();
    mTopStageAfterFoldDismiss = STAGE_TYPE_UNDEFINED;
    Slog.i(TAG, "applyExitSplitScreen, reason = " + exitReasonToString(exitReason));
    // Log the exit
    if (childrenToTop != null) {
        logExitToStage(exitReason, childrenToTop == mMainStage);
    } else {
        logExit(exitReason);
    }
}

核心就是复原,把之前分屏的操作回退掉

  • 把应用Task还原到DefaultTaskDisplayArea节点下,同时把需要显示的应用Task放在最上面
  • 把Divider设置成不可见

还是通过WindowOrganizer框架实现,像之前说的,所有改动都封装到WindowContainerTransaction中了,具体改动如下图

返回键退出

SystemUI监听了系统的Task变化,当返回触发一个Task消失,SystemUI得到通知就会判断是否是分屏中的Task,如果分屏中RootTask没有child就会触发退出分屏操作,退出分屏的逻辑和上面是一致的。

调用链路如下

分屏状态下上滑回到Launcher也会退出分屏

还是监听TaskInfo的变化,如果判断是分屏的Task就退出分屏状态,虽然退出,但是任务管理器中还是分屏状态的截图,再次点击又会重新触发分屏行为

调用链路如下

其他场景

分屏状态下应用以new task的方式启动另一个应用

可以看到WindowDemosActivity以new task方式启动SplitActivityList,最后两个task都挂在了分屏RootTask节点之下,返回的时候也会一个一个退出,直到分屏RootTask没有child就会退出分屏

旋转

分屏状态下旋转会从左右分屏变换到上下分屏,核心就是修改Bounds,还是通过WindowOrganizer框架把横屏Bounds改成竖屏Bounds

调用链路如下

核心方法如下:

scss 复制代码
// StageCoordinator.java
@Override
public void onLayoutSizeChanged(SplitLayout layout) {
    final WindowContainerTransaction wct = new WindowContainerTransaction();
    updateWindowBounds(layout, wct);
    updateUnfoldBounds();
    mSyncQueue.queue(wct);
    mSyncQueue.runInSync(t -> {
        updateSurfaceBounds(layout, t);
        mMainStage.onResized(getMainStageBounds(), t);
        mSideStage.onResized(getSideStageBounds(), t);
    });
    mLogger.logResize(mSplitLayout.getDividerPositionAsFraction());
}

接着我们看看核心的WindowContainerTransaction,如下图 可以看到这里就是把Bounds改成了竖屏Bounds

什么应用可拉伸

最开始我把开发者选项中强制resizable打开了,后来想看看应用的兼容性,就关了,关后发现基本所有应用也还是支持分屏的,所以我就去看了看resizable的代码

typescript 复制代码
// Task.java
boolean isResizeable() {
    return isResizeable( /* checkPictureInPictureSupport */ true);
}

boolean isResizeable(boolean checkPictureInPictureSupport) {
    final boolean forceResizable = mAtmService.mForceResizableActivities
            && getActivityType() == ACTIVITY_TYPE_STANDARD;
    return forceResizable || ActivityInfo.isResizeableMode(mResizeMode)
            || (mSupportsPictureInPicture && checkPictureInPictureSupport);
}


// ActivityInfo.java
@UnsupportedAppUsage
public static boolean isResizeableMode(int mode) {
    return mode == RESIZE_MODE_RESIZEABLE
            || mode == RESIZE_MODE_FORCE_RESIZEABLE
            || mode == RESIZE_MODE_FORCE_RESIZABLE_PORTRAIT_ONLY
            || mode == RESIZE_MODE_FORCE_RESIZABLE_LANDSCAPE_ONLY
            || mode == RESIZE_MODE_FORCE_RESIZABLE_PRESERVE_ORIENTATION
            || mode == RESIZE_MODE_RESIZEABLE_VIA_SDK_VERSION;
}

以下三种场景是Resizable

  • 开发者选项可以强制使所有应用变成Resizable,也就是mAtmService.mForceResizableActivities
  • 支持画中画也是Resizable
  • Activity的Resizable为指定的模式可以支持拉伸

再继续追一下Activity的ResizableMode

RESIZE_MODE_RESIZEABLE_VIA_SDK_VERSION 现在看大部分应用都是这个,也就是应用没有指定resizeMode,但是如果targetSdk >= N 系统就会认为是可拉伸;

当targetSdk < N 的时候,如果是指定了screenOrientation为竖屏,resizeMode就会变成RESIZE_MODE_FORCE_RESIZABLE_PORTRAIT_ONLY(强制可拉伸,但是只能保持竖屏) 如果指定了横屏,RESIZE_MODE_FORCE_RESIZABLE_LANDSCAPE_ONLY(强制可拉伸,但是只能保持横屏) 剩余就是 RESIZE_MODE_FORCE_RESIZABLE_PRESERVE_ORIENTATIONRESIZE_MODE_FORCE_RESIZEABLE 所以综上看,应用如果没有指定resizeActivity为false,默认都是可以拉伸的。

当我强制把resizeActivity设置成false,这样肯定不能分屏了吧,但是意想不到的是还是能分屏,再继续追一下源码发现分屏还有一个单独的判断,具体逻辑在 Task.supportsSplitScreenWindowingMode

Java 复制代码
boolean supportsMultiWindowInDisplayArea(@Nullable TaskDisplayArea tda) {  
    if (!mAtmService.mSupportsMultiWindow) {  
        return false;  
    }  
    final Task task = getTask();  
    if (task == null) {  
        return false;  
    }  
    if (tda == null) {  
        Slog.w(TAG, "Can't find TaskDisplayArea to determine support for multi"  
        + " window. Task id=" + getTaskId() + " attached=" + isAttached());  
        return false;  
    }  
    if (!getTask().isResizeable() && !tda.supportsNonResizableMultiWindow()) {  
        // Not support non-resizable in multi window.  
        return false;  
    }  

    final ActivityRecord rootActivity = getTask().getRootActivity();  
    return tda.supportsActivityMinWidthHeightMultiWindow(mMinWidth, mMinHeight,  
            rootActivity != null ? rootActivity.info : null);  
}

所以还跟TaskDisplayArea.supportsNonResizableMultiWindow 有关系,具体可以自己看看代码

总结

  • SystemUI进程初始化阶段创建好分屏的RootTask节点,为后续分屏做准备
  • Launcher3中任务管理界面触发分屏,然后Launcher做分屏前的动画,当动画结束通知SystemUI触发正常分屏
  • SystemUI收到分屏通知,先显示分屏中间的DividerView,然后将应用Task挂载到初始化阶段创建好的RootTask节点下,同时修改对应的Bounds
  • 用户拉伸修改分屏比例的时候,SystemUI进程中DividerView收到Touch事件,然后显示拉伸过程中应用上的遮罩,然后修改对应的RootTask的Bounds。
  • 当用户拉伸退出分屏,SystemUI进程触发退出动画,动画结束将分屏Task的节点还原到DefaultTaskDisplayArea,并隐藏分屏的RootTask节点,把需要显示应用的Task放到最前面

至此整个分屏流程就已经分析完成了 整个过程用了一些 Framework 提供的基础能力 WindowOrganizerSurfaceControlViewHost 前者用途非常广泛,基本SystemUI所有feature都有涉及,后者也用的比较多,可以实现跨进程显示View,比如改版后的StartingWindow,还有包括Google现在都还没有打开的ShellTransition,如果大家有兴趣,后续可以再写写。

相关推荐
网络研究院1 小时前
Android 安卓内存安全漏洞数量大幅下降的原因
android·安全·编程·安卓·内存·漏洞·技术
凉亭下1 小时前
android navigation 用法详细使用
android
小比卡丘4 小时前
C语言进阶版第17课—自定义类型:联合和枚举
android·java·c语言
前行的小黑炭5 小时前
一篇搞定Android 实现扫码支付:如何对接海外的第三方支付;项目中的真实经验分享;如何高效对接,高效开发
android
落落落sss6 小时前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
代码敲上天.7 小时前
数据库语句优化
android·数据库·adb
GEEKVIP9 小时前
手机使用技巧:8 个 Android 锁屏移除工具 [解锁 Android]
android·macos·ios·智能手机·电脑·手机·iphone
model200511 小时前
android + tflite 分类APP开发-2
android·分类·tflite
彭于晏68911 小时前
Android广播
android·java·开发语言
与衫12 小时前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql