Android 副屏录制方案

前言

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的,很少录制整个系统的,按场景合理选择就好。

相关推荐
siwangqishiq27 分钟前
Vulkan Tutorial 教程翻译(三) 绘制三角形 2.1 安装
前端
LaughingZhu8 分钟前
PH热榜 | 2025-06-05
前端·人工智能·经验分享·搜索引擎·产品运营
大模型真好玩8 分钟前
最强大模型评测工具EvalScope——模型好不好我自己说了算!
前端·人工智能·python
雨白9 分钟前
Fragment 入门教程:从核心概念到实践操作
android
烈焰晴天16 分钟前
使用ReactNative加载Svga动画支持三端【Android/IOS/Harmony】
android·react native·ios
Dream耀24 分钟前
CSS选择器完全手册:精准控制网页样式的艺术
前端·css·html
wordbaby24 分钟前
React 19 亮点:让异步请求和数据变更也能用 Transition 管理!
前端·react.js
月亮慢慢圆25 分钟前
VUE3基础之Hooks
前端
我想说一句26 分钟前
CSS 基础知识小课堂:从“选择器”到“声明块”,带你玩转网页的时尚穿搭!
前端·javascript·面试