Android N Split Window

前言

Android N引入了分屏的概念,有两个入口:

  • 单击多任务按键,通过长按拖动多任务界面中的task到顶部dock区域后释放。
  • 进入到某个应用(非Launcher)后,长按多任务键

对应的,另外一个区域的应用,则是通过点击多任务中的图标来启动。

Split Window First

Dock

从拖动入手,可以接受拖动目标的接口为,DropTarget,对应的,每部手机 or Set会因为设备不同而做不同的配置,对应的dock热区也会不同,以模拟器Nexus 5为例,dock的热区在顶部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
TaskStack.java
public static class DockState implements DropTarget {
......
public static final DockState TOP = new DockState(DOCKED_TOP,
DOCKED_STACK_CREATE_MODE_TOP_OR_LEFT, DOCK_AREA_ALPHA, 0, HORIZONTAL,
new RectF(0, 0, 1, 0.125f), new RectF(0, 0, 1, 0.125f),
new RectF(0, 0, 1, 0.5f));
......
@Override
public boolean acceptsDrop(int x, int y, int width, int height, boolean isCurrentTarget) {
return isCurrentTarget
? areaContainsPoint(expandedTouchDockArea, width, height, x, y)
: areaContainsPoint(touchArea, width, height, x, y);
}
......
/**
* @param createMode used to pass to ActivityManager to dock the task
* @param touchArea the area in which touch will initiate this dock state
* @param dockArea the visible dock area
* @param expandedTouchDockArea the areain which touch will continue to dock after entering
* the initial touch area. This is also the new dock area to
* draw.
*/
DockState(int dockSide, int createMode, int dockAreaAlpha, int hintTextAlpha,
@TextOrientation int hintTextOrientation, RectF touchArea, RectF dockArea,
RectF expandedTouchDockArea) {
this.dockSide = dockSide;
this.createMode = createMode;
this.viewState = new ViewState(dockAreaAlpha, hintTextAlpha, hintTextOrientation,
R.string.recents_drag_hint_message);
this.dockArea = dockArea;
this.touchArea = touchArea;
this.expandedTouchDockArea = expandedTouchDockArea;
}
......
}

What is a Dock ?

对于这里的定义,dock为Top。

1
2
3
4
public static final DockState TOP = new DockState(DOCKED_TOP,
DOCKED_STACK_CREATE_MODE_TOP_OR_LEFT, DOCK_AREA_ALPHA, 0, HORIZONTAL,
new RectF(0, 0, 1, 0.125f), new RectF(0, 0, 1, 0.125f),
new RectF(0, 0, 1, 0.5f));

而对于Top是如何被作为mDropTarget的,可以参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
RecentsViewTouchHandler.java
public final void onBusEvent(DragStartEvent event){
......
TaskStack.DockState[] dockStates = getDockStatesForCurrentOrientation();
for (TaskStack.DockState dockState : dockStates) {
registerDropTargetForCurrentDrag(dockState);
dockState.update(mRv.getContext());
mVisibleDockStates.add(dockState);
......
}
/**
* Returns the preferred dock states for the current orientation.
*/
public TaskStack.DockState[] getDockStatesForCurrentOrientation() {
boolean isLandscape = mRv.getResources().getConfiguration().orientation ==
Configuration.ORIENTATION_LANDSCAPE;
RecentsConfiguration config = Recents.getConfiguration();
TaskStack.DockState[] dockStates = isLandscape ?
(config.isLargeScreen ? DockRegion.TABLET_LANDSCAPE : DockRegion.PHONE_LANDSCAPE) :
(config.isLargeScreen ? DockRegion.TABLET_PORTRAIT : DockRegion.PHONE_PORTRAIT);
return dockStates;
}
public static TaskStack.DockState[] PHONE_LANDSCAPE = {
// We only allow docking to the left for now on small devices
TaskStack.DockState.LEFT
};
public static TaskStack.DockState[] PHONE_PORTRAIT = {
// We only allow docking to the top for now on small devices
TaskStack.DockState.TOP
};

因此,当整个拖拽的动作开始时,会触发onBusEvent(DragStartEvent event),从而准备去注册DropTarget,而这里DockStates的来源是:getDockStatesForCurrentOrientation();,在这个函数中我们会去判定当前set(手机or平板)的各种状态,从而给出不同位置的Dock。而在这里我们为了简化问题,就用Phone | Portrait为例。
因此:DockRegion.PHONE_PORTRAIT=TaskStack.DockState.TOP
最后,TOP与Phone Portrait的关系就这样建立起来了。

How to start a split Activity ?

上文分析了对于Dock为什么会出现在Top,以及Top & DropTarget的关系,所以当我们触发了Drag & Drop的动作以后,自然就会发生分屏这样一个行为了。 我们先看一张函数调用图来快速定位:

从函数调用栈上,我们了解到整个touch的派发是从PhoneWindow下来的,这一路是基于Frameworks的事件流,我们来看看RecentsViewTouchHandler.java中的handleTouchEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void handleTouchEvent(MotionEvent ev) {
......
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
if (mDragRequested) {
boolean cancelled = action == MotionEvent.ACTION_CANCEL;
if (cancelled) {
EventBus.getDefault().send(new DragDropTargetChangedEvent(mDragTask, null));
}
EventBus.getDefault().send(new DragEndEvent(mDragTask, mTaskView,
!cancelled ? mLastDropTarget : null));
break;
......
}

搭上EventBus一路向北

我们来到RecentView.java::onBusEvent(final DragEndEvent event)

1
2
3
4
5
public final void onBusEvent(final DragEndEvent event) {
......
if (ssp.startTaskInDockedMode(event.task.key.id, dockState.createMode)) {
......
}

sspSystemServicesProxy的实例,而对应的入参:event.task.key.id是需要启动的task id,dockState.createMode则是启动的mode,这个在创建DockState的时候已经确定:

1
2
3
4
5
6
7
8
public static final DockState LEFT = new DockState(DOCKED_LEFT,
DOCKED_STACK_CREATE_MODE_TOP_OR_LEFT, ......);
public static final DockState TOP = new DockState(DOCKED_TOP,
DOCKED_STACK_CREATE_MODE_TOP_OR_LEFT, ......);
public static final DockState RIGHT = new DockState(DOCKED_RIGHT,
DOCKED_STACK_CREATE_MODE_BOTTOM_OR_RIGHT, ......);
public static final DockState BOTTOM = new DockState(DOCKED_BOTTOM,
DOCKED_STACK_CREATE_MODE_BOTTOM_OR_RIGHT,......);

createMode中的的两个参数,很不幸,都是属于ActivityManager.java中的hide元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Input parameter to {@link android.app.IActivityManager#moveTaskToDockedStack} which
* specifies the position of the created docked stack at the top half of the screen if
* in portrait mode or at the left half of the screen if in landscape mode.
* @hide
*/
public static final int DOCKED_STACK_CREATE_MODE_TOP_OR_LEFT = 0;
/**
* Input parameter to {@link android.app.IActivityManager#moveTaskToDockedStack} which
* specifies the position of the created docked stack at the bottom half of the screen if
* in portrait mode or at the right half of the screen if in landscape mode.
* @hide
*/
public static final int DOCKED_STACK_CREATE_MODE_BOTTOM_OR_RIGHT = 1;

根据字面意义,我们也大概可以猜测到,当value = 0也即DOCKED_STACK_CREATE_MODE_TOP_OR_LEFT,其实就是把Activity启动时放在上面or左边(上左优先),当value = 1也即DOCKED_STACK_CREATE_MODE_BOTTOM_OR_RIGHT,其实就是右下优先。

StartTaskInDockedMode

紧接着我们来到真正的执行点,ssp.startTaskInDockedMode

1
2
3
4
5
6
7
8
public boolean startTaskInDockedMode(int taskId, int createMode) {
......
final ActivityOptions options = ActivityOptions.makeBasic();
options.setDockCreateMode(createMode);
options.setLaunchStackId(DOCKED_STACK_ID);
mIam.startActivityFromRecents(taskId, options.toBundle());
......
}

AMS,稍后开专门的文章来分析。

Split Window Second

主屏应用分屏结束后,多任务列表的界面会存在与从屏中,此时屏幕差不多是这样显示的:

上面的部分我们认为是主屏,也就是之前分析的DockState,TOP状态下的左上优先。底下的部分我们认为是从屏,可以看到它仍旧是处于多任务列表的模式

注意请忽略掉星星的图标,原生的代码中并没有那一块东西,这里是我在调试UI的时候加入的

StartActivity to slave window

这边是单击事件来触发整个启动的flow,而从这个View的结构我们已经知道每一个task其实是一个TaskView,因此我们直接来到TaskView.java并注意看它的onClick函数:

1
2
3
4
5
6
7
@Override
public void onClick(final View v) {
......
EventBus.getDefault().send(new LaunchTaskEvent(this, mTask, null, INVALID_STACK_ID,
screenPinningRequested));
......
}

在这里,我们需要看一下LaunchTaskEvent的构造参数:

1
2
3
4
5
6
7
8
LaunchTaskEvent(TaskView taskView, Task task, Rect targetTaskBounds, int targetTaskStack,
boolean screenPinningRequested {
this.taskView = taskView;
this.task = task;
this.targetTaskBounds = targetTaskBounds;
this.targetTaskStack = targetTaskStack;
this.screenPinningRequested = screenPinningRequested;
}

其中:

taskView:this
task:mTask
targetTaskBounds:null
targetTaskStack:INVALID_STACK_ID
screenPinningRequested: screenPinningRequested

再次搭上EventBus一路向北

这次我们来到了RecentsView.java::onBusEvent

1
2
3
4
5
public final void onBusEvent(LaunchTaskEvent event) {
mLastTaskLaunchedWasFreeform = event.task.isFreeformTask();
mTransitionHelper.launchTaskFromRecents(mStack, event.task, mTaskStackView, event.taskView,
event.screenPinningRequested, event.targetTaskBounds, event.targetTaskStack);
}

这里首先去检查了一下event.task的isFreefromTask,而这个FreeForm其实就是android N上最新加入的隐藏模式,也就是悬浮窗口,当然这里的悬浮窗与windows上的那种还有区别,它并不支持background activity,对于所有的FreeForm activity,他们都是基于一个统一的wallpaper,然后在其之上堆叠显示。

RecentsTransitionHelper.java的神秘面纱

1
2
3
4
5
6
7
8
public void launchTaskFromRecents(final TaskStack stack, @Nullable final Task task,
final TaskStackView stackView, final TaskView taskView,
final boolean screenPinningRequested, final Rect bounds, final int destinationStack) {
......
startTaskActivity(stack, task, taskView, opts, transitionFuture,
animStartedListener);
......
}

想想也是很愚蠢的,所以这边最终还是会进入到startTaskActivity的flow,继续trace:

1
2
3
4
5
6
7
8
9
10
RecentsTransitionHelper.java
private void startTaskActivity(TaskStack stack, Task task, @Nullable TaskView taskView,
ActivityOptions opts, IAppTransitionAnimationSpecsFuture transitionFuture,
final ActivityOptions.OnAnimationStartedListener animStartedListener) {
SystemServicesProxy ssp = Recents.getSystemServices();
if (ssp.startActivityFromRecents(mContext, task.key, task.title, opts)) {
......
}
}

Go on for waiting AMS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean startActivityFromRecents(Context context, Task.TaskKey taskKey, String taskName,
ActivityOptions options) {
if (mIam != null) {
try {
if (taskKey.stackId == DOCKED_STACK_ID) {
// We show non-visible docked tasks in Recents, but we always want to launch
// them in the fullscreen stack.
if (options == null) {
options = ActivityOptions.makeBasic();
}
options.setLaunchStackId(FULLSCREEN_WORKSPACE_STACK_ID);
}
mIam.startActivityFromRecents(
taskKey.id, options == null ? null : options.toBundle());
return true;
} catch (Exception e) {
Log.e(TAG, context.getString(R.string.recents_launch_error_message, taskName), e);
}
}
return false;
}

最后是走到了startActivityFromRecents

Another way to SplitWindow

关于长按多任务键,从已经启动的某个app中直接进入到

#写在最后
纵观整个flow,去掉system ui特有的属性,对于启动一个主屏的应用:

1
2
3
4
5
6
7
8
public boolean startTaskInDockedMode(int taskId, int createMode) {
......
final ActivityOptions options = ActivityOptions.makeBasic();
options.setDockCreateMode(createMode);
options.setLaunchStackId(DOCKED_STACK_ID);
mIam.startActivityFromRecents(taskId, options.toBundle());
......
}

其中需要设置的是createMode以及Task id。
而对于启动一个从屏的应用,则是

1
2
mIam.startActivityFromRecents(
taskKey.id, options == null ? null : options.toBundle());