Created: April 12, 2023 3:24 PM Status: In Progress
picture-in-picture (PiP) 从Android 8上开始出现, 是一种特殊的multi-window mode
和自由窗口最大的区别是画中画窗口更多的用于展示内容如视频, 表面会覆盖一层菜单界面用来控制media, 并且用户不能和存在于pip mode的activity的界面交互。
从Android 12开始:
- 单击: 展示操控界面(最大化按钮, 设置按钮, 关闭按钮,...)
- 双击: 最大化/最小化当前PiP window
- 拖动: 在屏幕上任意移动; stash window到屏幕边缘, if stashed, 单击或拖动窗口还原
- pinch-to-zoom(两指缩放): 改变PiP window 大小
- 拖动四角缩放(onDragCornerResize)
Google官方的pip 应用例子: https://github.com/android/media-samples/tree/main/PictureInPicture/#readme
官方文档:https://developer.android.com/develop/ui/views/picture-in-picture
- 在AndroidManifest.xml中配置, actvity允许进入PiP
<**activity** android:name=".MainActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:supportsPictureInPicture="true">
- App端主动进入pip模式
*@Deprecated*
public void enterPictureInPictureMode() {
enterPictureInPictureMode(new PictureInPictureParams.Builder().build());
}
enterPictureInPictureMode(@NonNull PictureInPictureParams *params*);
App用来自定义一些pip的样式,动画过渡等等功能
几个常用params的例子:
.setAutoEnterEnabled(true)
: 退出activit时自动进入pip- 一些常见的视频app都会有类似的功能, 视频界面时退到home自动弹出小窗
- 在老一些的版本中没有这个接口。实现这个效果是通过Activity中override
onUserLeavHint()
, 里面主动调用enterPictureInPictureMode
.setSourceRectHint(Rect)
: 提过提供期望截图hint rect, 进入pip的动画会更加丝滑- 默认过渡是颜色遮罩
- 提供hint rect时会用截图做遮罩, 所以动画效果更好
.setAspectRatio()
: 设置pip window的窗口比例- 默认比例是16:9, landscape
<!-- The default aspect ratio for picture-in-picture windows. -->
<item name="config_pictureInPictureDefaultAspectRatio" format="float" type="dimen">
1.777778
</item>
- 也可以自己自定义成竖屏的pip window: .setAspectRatio(new Rational(9, 16))
通过App端调用enterPictureInPictureMode
通知ATMS和WM core开始对task结构和configuration做相应的变化。在变化过程中WM Shell通过TaskOrganizer
感知到window mode的变化(WINDOWING_MODE_PINNED)再通过TaskListener callback
告诉PipTaskOrganizer
完成对应的独立动画,和input相关事件的注册等。
关键代码:
-
RootWindowContainer#moveActivityToPinnedRootTask:
mService.deferWindowLayout()
rootTask.setWindowingMode(WINDOWING_MODE_PINNED)
-> 有一些关于WINDOWING_MODE_PINNED变化的特殊处理rootTask.setDeferTaskAppear(false)
-> onTaskAppeared PendingTaskEventmService.continueWindowLayout()
-> dispatchPendingEventsnotifyActivityPipModeChanged(*r*.getTask(), *r*)
-> PipController onActivityPinned listeners
-
提供进入pip接口, 客户端最终调用到
enterPictureInPictureMode
通知ATMS当前Activity进入pip模式ActivityTaskManagerService#enterPictureInPictureMode
-
ATMS持有mRootWindowContainer, 作为WindowConfiguration树形结构的root节点(全局单例), 开始"指挥"当前task完成相对应的操作
RootWindowContainer#moveActivityToPinnedRootTask(...)
- RootWindowContainer完成了对即将要进入pip task的window mode的改变, 但是此时, 改变的仅仅是windowmode, task surface的大小在等待Pip模块完成动画后由
WindowContainerTransaction
通知更新
-
ActivityRecord#finishing
见下面视频, 两种不同的情况
- 在不同场景下, WM Core会让Shell感知到Task的变化, PiP模块就可以根据container的不同变化场景完成不同的独立动画
- TaskOrganizerController
- onTaskAppeared
- onTaskInfoChaned
- onTaskVanished
- DispatchPendingEvents
- TaskOrganizerController
- 通过
ShellTaskOrganizer
感知task的变化- onTaskAppeared
- onTaskInfoChanged
- onTaskVanished
- 不同模块注册ShellTaskOrganizer.TaskListener
TaskListener
是shell端的calback, 可以理解为ShellTaskOrganizer
作为shell的对接人在接受wm core发来的task变化的情况, 再通过TaskListener
告诉shell的对应模块发生了什么
e.g.
- PipTaskOrganizer implements ShellTaskOrganizer.TaskListener
- StageTaskOrganizer implements ShellTaskOrganizer.TaskListener
需要注意的细节是onTaskInfoChanged接口不一定对应TaskListener的onTaskInfoChanged.
e.g.
WM core告诉ShellTaskOrganizer
onTaskInfoChanged, 对应pip可能感知到的是 onTaskAppeared
比如在"WM Core中的相关处理"的结构图中,如果RootWindowContainer没有新建task, 把当前唯一ActivityRecord的Task变成了PINNED task, 这时候ShellTaskOrganizer感知到的是task info的变化。但是对于PipTaskOrganizer
应该理解成onTaskAppeared (on PINNED task appeared), 所以源码中区分了这一点, 在ShellTaskOrganizer的onTaskInfoChanged中尝试更新callback ShellTaskOrganizer#updateTaskListenerIfNeeded
,这样做确保了Pip可以在处理task变化的逻辑时做到统一
- 通过TaskStackListener:
PipController
- onActivityPinned 注册4个listeners
PipResizeGestureHandler
: 处理pinch-resize, onDragCornerResize, dismiss-targetPipInputConsumer
: 处理移动Pip, pip menu touch 事件的接受PipMediaController
: Pip menu对于视频media的控制PipAppOpsListener
: 监听app设置相关变化, runtime permissions access
- onActivityUnPinned 销毁listeners
我理解TaskStackListener
和TaskOrganizer
都是为了感知WM core中container的变化(可能一个针对task一个针对activityrecord), 看代码的区别是感知的时机不同, 还有其他区别吗? 为什么不能只用其中一个在pip中做事情?
Pip 作为一个单独的模块在shell 中独立处理了动画实现, 包括手势缩放动画, 窗口变化的动画。完成了两种input消费的处理, task surface大小的input consumer和屏幕大小的gesture monitor。在对task做变化(移动, 缩放, 进入退出pip等)的过程和完成时, 通过SurfaceControl.Transaction 和 WindowContainerTransaction通知WM core相关变化
- 在RootWindowContainer对task进行WindowMode的改变后,
rootTask.setDeferTaskAppeared(false)
会让TaskOrganizerController
在根据不同的场景添加PendingTaskEvent
, 这个event会在这之后的continueLayout
流程中会被dispatchPendingEvents
发送给TaskOrganizer
PipTaskOraganizer
在感知到Task的变化并且拿到taskInfo和leash后, 就会通过PipAnimationController
触发pip独立的动画PipAnimationController.PipTransitionAnimator
本身是一种ValueAnimator, 换句话说, 通过ValueAnimator作为驱动, 去不断的更新task的SurfaceControl, 达到了动画的效果。- 在动画结束时, Pip再通过WindowContainerTransaction向WM core更新bounds, activityWindowingMode等
动画驱动-PipAnimationController
- AnimationType
- ANIM_TYPE_BOUNDS
- ANIM_TYPE_ALPHA
PipTansitionAnimator
abstract- 作为PipAnimationController的内部类, 是一种ValueAnimator, 驱动整个pip的动画系统, 对task的SurfaceControl做操作
- 在PipAnimationController中静态实现了两种concrete class:
- ofBounds
- ofAlpha
- pip模块会根据不同的场景通过controller拿到上面两种不同的animator实现,
PipAnimationController#getAnimator
- PipTransitionAnimator作为一个抽象类,封装好了pip在做不同动画时的通用逻辑, 如应用
SurfaceControlTransaction
对leash的操作, 添加/删除PipContentOverlay
, 插入通用的PipAnimationCallback
逻辑等。 并且规定好了generic type的变量已经需要定制的操作:- T mBaseValue;
- T mCurrentValue;
- T mStartValue;
- T mEndValue;
- appySurfaceTransaction()
- ...
- 两个不同的具体实现ofBounds, ofAlpha分别对应Rect 和 float
动画遮罩-PipContentOverlay
pip在做动画的时候会根据app的提供的不同的PipParameters使用不同的遮罩:
- PipColorOverlay
- PipSnapShotOverlay
PipContentOverlay被reparent到Task leash, Integer.MAX_VALUE确保overlay在所有sibilings中z-order是最高的
💡 做动画时的SurfaceControl操作还是在task的leash上完成, see `SurfaceControl#reparent`Re-parents a given layer to a new parent. Children inherit transform (position, scaling) crop, visibility, and Z-ordering from their parents, as if the children were pixels within the parent Surface.
App端如果提供了有效的sourceRectHint就会使用PipSnapShotOverlay; 如果没有提供sourceRectHint或者是无效的, 就会使用默认的PipColorOverlay
PipContentOverlay提供了3种callback来定制不同overlay的表现:
- attach
- onAnimationUpdate
- onAnimationEnd
比如, SnapshotOverlay在onAnimationUpdate的时候不需要做任何事情, 跟着parentLeash动就可以; ColorOverlay的逻辑则是需要用适当的方式在做动画时更新纯色遮罩的透明度, 来达到相对好的效果。
两种不同的遮罩样式:
暂时无法在文档外展示此内容
动画代码示例
关键代码
- PipAnimationController
- PipSurfaceTransactionHelper: 里面封装了对surface在pip不同场景下的组合操作
PipAnimationController在配置好之后就会启动PipTransitionAnimator,这时属性动画开始根据设置好的动画曲线对常量值做改变, 在不同的ValueAnimator.AnimatorUpdateListener
, 回调中做不同的事情, 关键的操作就是通过PipSurfaceTransactionHelper来对leash做各种变化来达到动画的效果
`// PipAnimationController
*@Override*
public void onAnimationUpdate(ValueAnimator *animation*) {
// customized by concrete classes
applySurfaceControlTransaction(mLeash, newSurfaceControlTransaction(),
*animation*.getAnimatedFraction());
}`
`// e.g.
// PipAnimationController#ofBounds
*@Override*
void applySurfaceControlTransaction(SurfaceControl *leash*,
SurfaceControl.Transaction *tx*, float *fraction*) {
final Rect base = getBaseValue();
final Rect start = getStartValue();
final Rect end = getEndValue();
if (mContentOverlay != null) {
mContentOverlay.onAnimationUpdate(*tx*, *fraction*);
}
...
Rect bounds = mRectEvaluator.evaluate(*fraction*, start, end);
float angle = (1.0f - *fraction*) * *startingAngle*;
setCurrentValue(bounds);
if (inScaleTransition() || *sourceHintRect* == null) {
//做不等比的scale
...
} else {
// fraction是现在动画常量变化完成的比例, 根据这个fraction计算出来一个预期的temp的切割rect, 用于后面的crop
final Rect insets = computeInsets(*fraction*);
getSurfaceTransactionHelper().scaleAndCrop(*tx*, *leash*,
*sourceHintRect*, initialSourceValue, bounds, insets,
isInPipDirection);
...
}
}
**see: PipSurfaceTransactionHelper#scaleAndCrop**// scale, crop, position(左上坐标位移)
*tx*.setMatrix(*leash*, mTmpTransform, mTmpFloat9)
// 这个mTmpDestinationRect就是上面computeInsets计算出来的这次动画update需要crop到的地方
.setCrop(*leash*, mTmpDestinationRect)
.setPosition(*leash*, left, top);`
- 注册InputConsumer接收移动, touch事件
- 注册"pip-resize"gesture monitor监听全局gesture
- pinch resize 双指缩放
- 开关配置:
PipResizeGestureHandler#mEnablePinchResize
- 开关配置:
- onDragCornerResize 拖拽四角缩放
- 开关配置:
PipResieGestureHandler#mEnableDragCornerResize
- 开关配置:
- pinch resize 双指缩放
移动单双击-InputConsumer
注册PipInputConusmer
`PipController#init() onActivityPinned()`
`mPipInputConsumer.registerInputConsumer();`
`public void registerInputConsumer() {
if (mInputEventReceiver != null) {
return;
}
final InputChannel inputChannel = new InputChannel();
try {
*// TODO(b/113087003): Support Picture-in-picture in multi-display.* mWindowManager.destroyInputConsumer(mName, DEFAULT_DISPLAY);
**mWindowManager.createInputConsumer(mToken, mName, DEFAULT_DISPLAY, inputChannel);** } catch (RemoteException e) {
ProtoLog.e(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: Failed to create input consumer, %s", TAG, e);
}
mMainExecutor.execute(() -> {
*// Choreographer.getSfInstance() must be called on the thread that the input event // receiver should be receiving events // TODO(b/222697646): remove getSfInstance usage and use vsyncId for transactions // YW_PIP_NOTE // TODO:* mInputEventReceiver = new InputEventReceiver(inputChannel,
Looper.myLooper(), Choreographer.getSfInstance());
if (mRegistrationListener != null) {
mRegistrationListener.onRegistrationChanged(true */* isRegistered */*);
}
});
}`
在上面注册InputConsumer的时候会发现并没有设置成task surface的大小, 这个逻辑是在InputMonitor中特殊处理了mPipInputConsumer
InputMonitor#UpdateInputForAllWindowsConsumer
// 特殊记录
mPipInputConsumer = getInputConsumer(INPUT_CONSUMER_PIP);
- 在
InputMonitor
中注册consumer的时候, 通过private final ArrayMap<String, InputConsumerImpl> mInputConsumers = new ArrayMap();
- 特殊记录了mPipInputConsumer, 并且在窗口
continueLayout
的时候更新InputConsumer的大小,也就是当task变成Pip window的时候, InputConsumer的touchableRegion更新成和task surface一样的大小
//InputMonitor.UpdateInputForAllWindowsConsumer#accpet(WindowState w)
if (w.inPinnedWindowingMode()) {
if (mAddPipInputConsumerHandle) {
*// YW_PIP_NOTE // update the mPipInputConsumer to cropped by the task bounds* final Task rootTask = w.getTask().getRootTask();
**mPipInputConsumer.mWindowHandle.replaceTouchableRegionWithCrop( rootTask.getSurfaceControl());** final DisplayArea targetDA = rootTask.getDisplayArea();
*// We set the layer to z=MAX-1 so that it's always on top.* if (targetDA != null) {
mPipInputConsumer.layout(mInputTransaction, rootTask.getBounds());
mPipInputConsumer.reparent(mInputTransaction, targetDA);
mPipInputConsumer.show(mInputTransaction, MAX_VALUE - 1);
mAddPipInputConsumerHandle = false;
}
}
}
dumpsys input 和 dumpsys window的对比, consumer的touchableRegion和task bounds是一致的, 这也是PiP其中一个特性的原因, app进入pip模式是不能再和app自己的界面交互的
adb shell dumpsys input | vim -
/pip_input_consumer
adb shell dumpsys window w | vim -
/mWindowingMode=pinned
缩放手势-Gesture Monitor
PipResizeGestureHandler
- onActivityPinned()中注册 也就是wm structure被改变完成的时候, 此时currTask.windwMode == PINNED_MODE
*// YW_PIP_NOTE// handle gestures and stuff// e.g. pinch gesture to resize, onDragCornerResize*mPipResizeGestureHandler.onActivityPinned();
if (mIsEnabled) {
// Register input event receiver
**mInputMonitor = InputManager.getInstance().monitorGestureInput( "pip-resize", mDisplayId);** try {
mMainExecutor.executeBlocking(() -> {
**mInputEventReceiver = new PipResizeInputEventReceiver( mInputMonitor.getInputChannel(), Looper.myLooper());** });
} catch (InterruptedException e) {
throw new RuntimeException("Failed to create input event receiver", e);
}
}
- 全局的手势监听
adb shell dumpsys input | vim -
/Gesture Monitor
- 拖拽四角缩放(onDragCornerResize)等手势实现
InputManager.getInstance().monitorGestureInput(IBinder monitorToken, @NonNull String requestedName, int displayI);
InputMonitor相关wiki: https://wiki.n.miui.com/display/~chuziqian/InputMonitor
不同场景下的dumpsys对比
移动
adb shell dumpsys input | vim -
/Input Dispatcher
onDragCorner
两指缩放
//TODO
SystemWindows
SurfaceControlViewHost
adb shell dumpsys activity service com.android.systemui
/PipController
//TODO
- PipTochHandler
- PipBoundsAlgorithm
- PipTaskOrganizer
- mPictureInPictureParams: 可以用这个来区分app是自己addView悬浮窗还是用的pip.
mPictureInPictureParams=PictureInPictureParams( aspectRatio=null #进入pip window 的 width / height ratio, null默认1.7 expandedAspectRatio=null sourceRectHint= Rect(0, 60 - 1600, 1060) #截图的提示rect hasSetActions=true hasSetCloseAction=false isAutoPipEnabled=false # onUserLeaveHint(), 返回桌面自动进入pip模式 isSeamlessResizeEnabld=true title=null subtitle=null isLaunchIntoPip=false )
- PipBoundsState
- PipInputConsumer