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,如果大家有兴趣,后续可以再写写。

相关推荐
黄林晴13 小时前
如何判断手机是否是纯血鸿蒙系统
android
火柴就是我13 小时前
flutter 之真手势冲突处理
android·flutter
法的空间13 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
循环不息优化不止13 小时前
深入解析安卓 Handle 机制
android
恋猫de小郭14 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
jctech14 小时前
这才是2025年的插件化!ComboLite 2.0:为Compose开发者带来极致“爽”感
android·开源
用户20187928316714 小时前
为何Handler的postDelayed不适合精准定时任务?
android
叽哥14 小时前
Kotlin学习第 8 课:Kotlin 进阶特性:简化代码与提升效率
android·java·kotlin
Cui晨14 小时前
Android RecyclerView展示List<View> Adapter的数据源使用View
android
氦客14 小时前
Android Doze低电耗休眠模式 与 WorkManager
android·suspend·休眠模式·workmanager·doze·低功耗模式·state_doze