Android分屏功能原理(基于Android12L)
分屏功能目的是为了提高用户的生产效率,提高多应用使用的便捷性;Android 很早版本就已经提供了分屏功能,不过随着版本的迭代,特别是Google开始关注Android大屏设备的用户使用体验,内部的实现逻辑也和以前有很大的差别
先来看看原生分屏的使用
值得注意的是原生Android分屏功能只允许在任务管理器中选择分屏应用,如果应用未打开过,就无法分屏
Android13上这一点有差别,Launcher支持图标长按触发分屏功能,不过分屏的另一个应用也只能在任务管理器中起。
本篇文章简单解析一下整体方案的架构,了解各个模块做了哪些事情,这里分4个阶段解析,初始化阶段,触发分屏阶段,分屏拉伸阶段,退出分屏阶段
初始化阶段
SystemUI在进程初始化阶段就已经准备好分屏所需要的 MainStage 和 SideStage 对象,这两个对象很重要,分别负责分屏的一边,对象内部会创建一个 RootTask 节点了(这里利用了WindowOrganizer框架的能力,如果有想了解后续单独写一篇文档),这个RootTask就是分屏的关键,通过把应用的Task节点挂载到RootTask下,然后修改RootTask节点的Bounds来改变应用显示的大小。
//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 触发分屏功能,细节代码如下:
// 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 进程触发分屏操作
//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) 支持新起一个应用
命令行触发分屏
// 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,然后申请分屏操作,这部分代码如下:
// 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进程初始化阶段会创建MainStage 和 SideStage,它们分别会创建RootTask,不仅如此,拉伸的时候也会由这两个类处理
拉伸过程方法如下
//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就是由这个类创建的
// 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进程,拉伸退出分屏,先是会触发退出动画,当动画结束就会退出分屏,链路如下
退出代码核心逻辑在以下
//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
调用链路如下
核心方法如下:
// 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的代码
// 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_ORIENTATION
和 RESIZE_MODE_FORCE_RESIZEABLE
所以综上看,应用如果没有指定resizeActivity为false,默认都是可以拉伸的。
当我强制把resizeActivity设置成false,这样肯定不能分屏了吧,但是意想不到的是还是能分屏,再继续追一下源码发现分屏还有一个单独的判断,具体逻辑在 Task.supportsSplitScreenWindowingMode
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 提供的基础能力 WindowOrganizer 和 SurfaceControlViewHost 前者用途非常广泛,基本SystemUI所有feature都有涉及,后者也用的比较多,可以实现跨进程显示View,比如改版后的StartingWindow,还有包括Google现在都还没有打开的ShellTransition,如果大家有兴趣,后续可以再写写。