前言
Android平台很早(Android 4.3)就支持了多屏渲染和多屏互动,但是众多设备制造商往往仅限于特定的领域硬件设备,比如有的硬件厂商仅仅制造手机,从而不会涉及大屏、车载屏等,因此无法将多屏渲染的能力充分展示出来。
不过也不是没人做,在iOT领域其实出现了各种各样的方案,这点iOT厂商反而做的比较多,因此iOT相关需求比较多。对于许多Android大屏、车机、KTV、课堂等场景而言,多屏互动具有很多吸引力。
之前我们的一篇文章是《Android Surface截图方法总结》,在那篇文章中,我们还提到过很多截屏方式,事实上,无论截屏还是录屏,前提都是需要访问的屏幕数据,当然,文章中我们也列出了访问屏幕数据一些方案。
副屏录制的难点
本篇,我们重点是副屏录制,首先我们要连接为什么副屏录制存在难点呢?
难点就是,在Android系统,MediaProjection框架式没法录制指定的屏幕,仅仅能够录制主屏幕。此外,如果使用其他框架,可能需要申请系统级别的权限,这是普通app无法逾越的障碍。
本篇思路
其实无论是哪种录制,均需要解决两个问题,第一个问题是投影,第二个是进行数据读取,本篇会换一种思路,在没有权限的情况下实现副屏录制。
基于现状,我们先来看看,市面上有哪些可选方案呢?
常用方案
其实细分下来,目前主流的实现方式都是围绕SurfaceFlinger的LayerStack投影来实现的,在SurfaceFlinger中,每个Display会对应一个LayerStack,管理者每个Display图像Buffer,基于这个框架,理论上我们获取到LayerStack的数据就能知道每个屏幕中展示的内容。
事实上,有很多方案也是这么干的。
scrcpy方案
scrcpy是genymition的杰作,其原理是利用SurfaceControl,创建一个离屏虚拟设备,接着通过display id获取到displayToken和LayerStack,最后利用SurfaceControl的相关方案将画面投影离屏虚拟设备。
优点是构思巧妙,耦合度低,但缺点是只能在ADB环境下使用,因为访问的SurfaceControl的相关接口,不仅仅需要屏幕录制权限,还需要SurfaceFlinger访问权限。
mirrorDisplay方案
其实此方案与我们之前的文章《Android mirrorSurface镜像分屏》有一定的关联,不同点是,mirrorSurface是利用SurfaceControl 离屏投影Surface,而mirror Display是利用SurfaceControl离屏投影屏幕,但底层都是mirror Surface。
然而,mirrorSurface和mirrorDisplay的最大不同点是,需要根据displayId获取到WindowManagerService中的SurfaceControl,最后方可实现投影。
java
@Override
public boolean mirrorDisplay(int displayId, SurfaceControl outSurfaceControl) {
if (!checkCallingPermission(READ_FRAME_BUFFER, "mirrorDisplay()")) {
throw new SecurityException("Requires READ_FRAME_BUFFER permission");
}
final SurfaceControl displaySc;
synchronized (mGlobalLock) {
DisplayContent displayContent = mRoot.getDisplayContent(displayId);
if (displayContent == null) {
Slog.e(TAG, "Invalid displayId " + displayId + " for mirrorDisplay");
return false;
}
displaySc = displayContent.getWindowingLayer();
}
final SurfaceControl mirror = SurfaceControl.mirrorSurface(displaySc);
outSurfaceControl.copyFrom(mirror, "WMS.mirrorDisplay");
return true;
}
在Android系统中,访问mirrorDisplay接口需要很多权限,其中之一就是READ_FRAME_BUFFER,除系统app外的普通app上没有此权限,也不可能申请到此权限。
ROM定制方案
实际上,很多厂商通过ROM定制的方式,给一些app开了暗门来访问LayerStack,但是也仅仅是只有特定类型的app才允许访问,这条路对普通开发者而言显然是行不通的。
合成方案
正常情况下,我们知道Android View都是通过View树中的RenderNode进行绘制的,显然,基于此方案,我们也可以拿到ViewRoot,遍历View树进行合成。但这里有个问题是,SurfaceView的合成难度相对较高,因为SurfaceView的绘制和普通View的绘制是分离的,还有涉及"挖洞"问题,难度有些大。
不过,如果使用TextureView,这个问题是可以避免的,但是无论是PixelCopy还是其他手段,需要相对好的CPU和内存空间,才能避免丢帧和性能问题。
本篇方案
实际上,本篇会提供两种方案,第一种是通过MediaProjection实现,另一种是更加通用的方案。
MediaProjection方案
我们知道,MediaProjection专为录屏而生,但是正常情况下,只能录制主屏幕,原因是在创建虚拟屏幕时,默认写死了主屏幕的DisplayId,但在Android 12开始,创建虚拟屏幕的方案中有个配置项。
java
android.hardware.display.VirtualDisplayConfig#mDisplayIdToMirror
然而,问题是,此配置项是@hide标记的,普通app无法访问。
投影
说到这里,似乎有些山清水尽,但我们可以拦截DisplayManagerService的Proxy Binder调用时对其进行拦截和修改。
java
public class CaptureViceDisplay implements InvocationHandler {
private final String TAG = "CaptureViceDisplay";
public static final String CaptureViceDisplayStartWithRule = "capture-vice-display#";
private final String ClassName_DisplayManagerGlobal = "android.hardware.display.DisplayManagerGlobal";
private final String ClassName_IDisplayManager = "android.hardware.display.IDisplayManager";
private final String ClassName_VirtualDisplayConfig = "android.hardware.display.VirtualDisplayConfig";
Class<?> DisplayManagerGlobal = null;
private Class<?> IDisplayManager;
private Method DisplayManagerGlobal_getInstance;
private Object mProxyInstance;
private Object mTargetInstance;
private static final CaptureViceDisplay instance = new CaptureViceDisplay();
private Context context;
private final Map<String,Integer> virtualDisplayMapMirrors = new HashMap<>();
public static CaptureViceDisplay getInstance() {
return instance;
}
public boolean hasProxy() {
return mProxyInstance != null;
}
public void init(Context context){
register();
if(hasProxy()){
this.context = context;
}else{
this.context = null;
}
}
void register() {
if (DisplayManagerGlobal == null) {
try {
DisplayManagerGlobal = Class.forName(ClassName_DisplayManagerGlobal);
} catch (Throwable e) {
e.printStackTrace();
}
}
if (IDisplayManager == null) {
try {
IDisplayManager = Class.forName(ClassName_IDisplayManager);
} catch (Throwable e) {
e.printStackTrace();
}
}
if (IDisplayManager == null || DisplayManagerGlobal == null) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
DisplayManagerGlobal_getInstance = HiddenApiBypass.getDeclaredMethod(DisplayManagerGlobal, "getInstance");
}
if (DisplayManagerGlobal_getInstance == null) {
try {
DisplayManagerGlobal_getInstance = DisplayManagerGlobal.getDeclaredMethod("getInstance");
} catch (Throwable e) {
e.printStackTrace();
}
}
Object DisplayManagerGlobal_Instance = null;
try {
DisplayManagerGlobal_Instance = DisplayManagerGlobal_getInstance.invoke(null);
} catch (Throwable e) {
e.printStackTrace();
}
if (DisplayManagerGlobal_Instance == null) {
return;
}
Field field = findField(DisplayManagerGlobal, "mDm");
Object IDisplayManager_instance = null;
try {
IDisplayManager_instance = field.get(DisplayManagerGlobal_Instance);
} catch (Throwable e) {
e.printStackTrace();
}
final Object proxyTargetInstance = IDisplayManager_instance;
Object proxyInstance = Proxy.newProxyInstance(IDisplayManager.getClassLoader(), new Class[]{
IDisplayManager
}, this);
try {
field.set(DisplayManagerGlobal_Instance, proxyInstance);
} catch (Throwable e) {
e.printStackTrace();
}
mProxyInstance = proxyInstance;
mTargetInstance = proxyTargetInstance;
}
private Field findField(Class<?> Klass, String name) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
List<Field> instanceFields = HiddenApiBypass.getInstanceFields(Klass);
for (Field f : instanceFields) {
if (f.getName().equals(name)) {
f.setAccessible(true);
return f;
}
}
}
try {
Field declaredField = Klass.getDeclaredField(name);
declaredField.setAccessible(true);
return declaredField;
} catch (Throwable e) {
e.printStackTrace();
}
try {
Field declaredField = Klass.getField(name);
declaredField.setAccessible(true);
return declaredField;
} catch (Throwable e) {
e.printStackTrace();
}
return null;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
boolean createVirtualDisplay = method.getName().equals("createVirtualDisplay");
if (!createVirtualDisplay || args == null || virtualDisplayMapMirrors.isEmpty()) {
return method.invoke(mTargetInstance, args);
}
Object VirtualDisplayConfig_instance = null;
for (int i = 0; i < args.length; i++) {
Object arg = args[i];
if(arg == null) continue;
if(arg.getClass().getName().equals(ClassName_VirtualDisplayConfig)){
VirtualDisplayConfig_instance = arg;
break;
}
}
if(VirtualDisplayConfig_instance == null){
return method.invoke(mTargetInstance, args);
}
Class<?> VirtualDisplayConfig = VirtualDisplayConfig_instance.getClass();
Field mName = findField(VirtualDisplayConfig, "mName");
String displayName = (String) mName.get(VirtualDisplayConfig_instance);
Integer displayId = virtualDisplayMapMirrors.get(displayName);
//根据名称进行拦截
if(displayId == null) {
Log.d(TAG,displayName +" is not supported to mirror");
return null;
}
Field mDisplayIdToMirror = findField(VirtualDisplayConfig, "mDisplayIdToMirror");
mDisplayIdToMirror.setInt(VirtualDisplayConfig_instance,displayId);
return method.invoke(mTargetInstance, args);
}
public void setDisplayToMirror(String virtualDisplayName, int mirrorDisplayId) {
virtualDisplayMapMirrors.put(virtualDisplayName,mirrorDisplayId);
}
}
本段代码通过动态代理DisplayManagerService的Binder调用,拦截createVirtualDisplay方法参数,并且根据【名称】进行修改特定投影的displayId。
上面的代码仅仅支持Android 12,之前的版本是没法修改DisplayId,另外就是,我们这里拦截DisplayManager而不是android.os.ServiceManager的原因是,DisplayManager在Application之前就初始化了,因此无法进行hook。
使用前我们需要初始化
java
public class HKApp extends Application {
@Override
public void onCreate() {
super.onCreate();
}
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
CaptureViceDisplay.getInstance().init(this);
}
}
下面代码是调用的,在创建虚拟屏时,创建特定名称的虚拟屏。MediaProjection的使用和之前一样,我们不再赘述,这里看投影的核心逻辑就行。
java
int mirrorDisplayId = getMirrorDisplayId(); //获取要投影的副屏id
String virtualDisplayName = CaptureViceDisplay.CaptureViceDisplayStartWithRule + "DemoView";
CaptureViceDisplay.getInstance().setDisplayToMirror(virtualDisplayName, mirrorDisplayId);
mVirtualDisplay = mMediaProjection.createVirtualDisplay(
virtualDisplayName,
mSurfaceView.getWidth(),
mSurfaceView.getHeight(),
1,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mSurface,
null,
mHandler);
下图被拖动的是通过开发者选项创建的副屏,其上面的内容被展示在主屏的SurfaceView上。

下面的图是默认录制主屏的效果

副屏内容实现如下
java
public class DemoPresentation extends Presentation {
private static final String TAG = "DemoPresentation";
private Handler mTimerHandler;
public DemoPresentation(Context context, Display display) {
super(context, display);
mTimerHandler = new Handler();
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.i(TAG, "onCreate");
setContentView(R.layout.my_presentation);
TextView tv = (TextView) findViewById(R.id.textView);
Animation myRotation = AnimationUtils.loadAnimation(getContext(), R.anim.rotator);
tv.startAnimation(myRotation);
final TextView timeTextView = (TextView) findViewById(R.id.tv_time);
final long startTime = System.currentTimeMillis();
final Runnable timerRunnable = new Runnable() {
@Override
public void run() {
long millis = System.currentTimeMillis() - startTime;
int seconds = (int) (millis / 1000);
int minutes = seconds / 60;
seconds = seconds % 60;
timeTextView.setText(String.format("%d:%02d", minutes, seconds));
mTimerHandler.postDelayed(this, 500);
}
};
mTimerHandler.postDelayed(timerRunnable, 0);
setOnDismissListener(new OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialogInterface) {
Log.i(TAG, "onDismiss");
mTimerHandler.removeCallbacks(timerRunnable);
}
});
}
}
我们实现了副屏投影,录制只需要将上面的SurfaceView的Surface替换为MediaRecorder、MediaCodec的Surface即可,这里我们不继续深入了。
通用方案
正常情况下,副屏的录制都是【副屏】投影到【虚拟屏】,最终输出到MediaRecoder中,上面的MediaProjection也是这种实现,另外mediaProjection也需要权限。
那么有没有更简单的方案呢?
投影
本篇肯定有更简单的方案,自然不用说。方案非常简单,甚至都不需要写代码。
我们换个思路,将投影流程进行修改。
- 旧的投影方式,【副屏】投影到【虚拟屏】
- 新的投影方式,【虚拟屏】投影到【副屏】
按照常规思路,我们将Presentaion展示在副屏上,在进行投影,这种录制上面我们例举了很多。假设,我们将内容先展示在虚拟屏上,在通过虚拟屏的Surface输出到副屏上,此也过程不需要权限。
另外,既然【虚拟屏】上就是我们想要的内容,我们可以利用【EGL离屏渲染机制】或者S【urfaceControl mirror机制】,将虚拟屏的内容分别输出给MediaRecoder和副屏,这样便实现了录制
总结
这里本篇就完成了,本篇,我们了解了常用方案,也列举了Android 12+之后的投影方案(需要权限和@hide隐藏api调用),也提出了一种更加简单的投屏方案。不过此方案的缺点也是有的,那就是只能录制自己app的内容。但常用的场景中,ktv、课堂也就是录制自身app的,很少录制整个系统的,按场景合理选择就好。