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

相关推荐
草莓熊Lotso2 小时前
Linux 文件描述符与重定向实战:从原理到 minishell 实现
android·linux·运维·服务器·数据库·c++·人工智能
恋猫de小郭2 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
工程师老罗9 小时前
如何在Android工程中配置NDK版本
android
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606110 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅11 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment11 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端