技术背景
好多开发者,希望我们能系统的介绍下无纸化同屏的原理和集成步骤,以Android平台为例,无纸化同屏将Android设备上的屏幕内容实时投射到另一个显示设备(如Windows终端、国产化操作系统或另一台Android设备)上,从而实现多屏互动和内容的无缝共享。
技术考量指标
本文以大牛直播SDK Android同屏采集推送为例,介绍下我们前些年做Android同屏采集推送的时候,一些注意点:
- 声明所需权限:在Android应用的AndroidManifest.xml文件中声明必要的权限;
- 获取MediaProjectionManager服务:在你Activity或Service,通过getSystemService方法获取MediaProjectionManager服务;
- 创建并启动屏幕捕获Intent:使用MediaProjectionManager的createScreenCaptureIntent方法创建一个Intent,该Intent会启动一个系统对话框,请求用户授权屏幕捕获;
- 处理用户授权结果:在onActivityResult回调中,根据用户授权的结果来获取MediaProjection对象;
- 创建VirtualDisplay并捕获屏幕:获得了MediaProjection对象,就可以使用它来创建一个VirtualDisplay,这个VirtualDisplay会捕获屏幕内容并将其发送到指定的Surface;
- 资源释放:当屏幕捕获不再需要时,确保释放MediaProjection和VirtualDisplay对象,以避免资源泄露;
- 视频编码:通过上述步骤,捕获带的屏幕内容需要进行视频编码,以便在网络中传输。如H.264、H.265等,以及设置合适的分辨率、帧率、码率,以适应不同的网络环境和接收设备的性能;
- 流媒体协议:为了将编码后的视频流实时传输到接收端,Android无纸化同屏技术通常采用RTMP推流模式或轻量级RTSP服务。
技术实现
本文以大牛直播SDK的Android的SmartServicePublisherV2的同屏demo为例,Android采集计时器,编码打包分别启动RTMP推送和轻量级RTSP服务,Windows过来分别拉取RTMP和RTSP的流,整体延迟毫秒级:
编辑
启动APP后,先选择需要采集的分辨率(如果选原始分辨率,系统不做缩放),然后选择“启动媒体投影”,并分别启动音频播放采集、采集麦克风。如果音频播放采集和采集麦克风都打开,可以通过右侧下拉框,推送过程中,音频播放采集和麦克风采集实时切换。需要注意的是,Android采集音频播放的audio,音频播放采集是依赖屏幕投影的,屏幕投影关闭后,音频播放也就采不到了。
编码的话,考虑到屏幕分辨率一般不会太低,我们可以缩放后再推送,默认我们开启了原始分辨率、标准分辨率、低分辨率选项设置。一般建议标准分辨率即可。如果对画质和分辨率要求比较高,可以选择原始分辨率。设备支持硬编码,优先选择H.264硬编,如果是H.265硬编,需要RTMP服务器支持扩展H.265(或Enhanced RTMP)。
都选择好后,设置RTMP推送的URL,点开始RTMP推送按钮即可。
如果需要通过轻量级RTSP服务,发布RTSP流,先点击启动RTSP服务按钮,RTSP服务启动后,再点击启动RTSP流,RTSP流发布成功后,界面会回调上来RTSP拉流的URL。
编辑
下面从代码逻辑实现角度,介绍下同屏的具体流程:
启动媒体服务,进入系统后,我们会自动启动媒体服务,对应的实现逻辑如下:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ private void start_media_service() { Intent intent = new Intent(getApplicationContext(), StreamMediaDemoService.class); if (Build.VERSION.SDK_INT >= 26) { Log.i(TAG, "startForegroundService"); startForegroundService(intent); } else startService(intent); bindService(intent, service_connection_, Context.BIND_AUTO_CREATE); button_stop_media_service_.setText("停止媒体服务"); } private void stop_media_service() { if (media_engine_callback_ != null) media_engine_callback_.reset(null); if (media_engine_ != null) { media_engine_.unregister_callback(media_engine_callback_); media_engine_ = null; } media_engine_callback_ = null; if (media_binder_ != null) { media_binder_ = null; unbindService(service_connection_); } Intent intent = new Intent(getApplicationContext(), StreamMediaDemoService.class); stopService(intent); button_stop_media_service_.setText("启动媒体服务"); }
需要注意的是,Android 6.0及以上版本,动态获取Audio权限:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ private boolean check_record_audio_permission() { //6.0及以上版本,动态获取Audio权限 if (PackageManager.PERMISSION_GRANTED == checkPermission(android.Manifest.permission.RECORD_AUDIO, Process.myPid(), Process.myUid())) return true; return false; } private void request_audio_permission() { if (Build.VERSION.SDK_INT < 23) return; Log.i(TAG, "requestPermissions RECORD_AUDIO"); ActivityCompat.requestPermissions(this, new String[] {android.Manifest.permission.RECORD_AUDIO}, REQUEST_AUDIO_CODE); } public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { switch(requestCode){ case REQUEST_AUDIO_CODE: if (grantResults != null && grantResults.length > 0 && PackageManager.PERMISSION_GRANTED == grantResults[0]) { Log.i(TAG, "RECORD_AUDIO permission has been granted"); }else { Toast.makeText(this, "请开启录音权限!", Toast.LENGTH_SHORT).show(); } break; } }
启动、停止媒体投影:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ private class ButtonStartMediaProjectionListener implements OnClickListener { public void onClick(View v) { if (null == media_engine_) return; if (media_engine_.is_video_capture_running()) { media_engine_.stop_audio_playback_capture(); media_engine_.stop_video_capture(); resolution_selector_.setEnabled(true); button_capture_audio_playback_.setText("采集音频播放"); button_start_media_projection_.setText("启动媒体投影"); return; } Intent capture_intent; capture_intent = media_projection_manager_.createScreenCaptureIntent(); startActivityForResult(capture_intent, REQUEST_MEDIA_PROJECTION); Log.i(TAG, "startActivityForResult request media projection"); } }
启动媒体投影后,选择“采集音频播放”,如果需要采集麦克风,可以点击“采集麦克风”:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ private class ButtonCaptureAudioPlaybackListener implements OnClickListener { public void onClick(View v) { if (null == media_engine_) return; if (media_engine_.is_audio_playback_capture_running()) { media_engine_.stop_audio_playback_capture(); button_capture_audio_playback_.setText("采集音频播放"); return; } if (!media_engine_.start_audio_playback_capture(44100, 1)) Log.e(TAG, "start_audio_playback_capture failed"); else button_capture_audio_playback_.setText("停止音频播放采集"); } } private class ButtonStartAudioRecordListener implements OnClickListener { public void onClick(View v) { if (null == media_engine_) return; if (media_engine_.is_audio_record_running()) { media_engine_.stop_audio_record(); button_start_audio_record_.setText("采集麦克风"); return; } if (!media_engine_.start_audio_record(44100, 1)) Log.e(TAG, "start_audio_record failed"); else button_start_audio_record_.setText("停止麦克风"); } }
启动、停止RTMP推送:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ private class ButtonRTMPPublisherListener implements OnClickListener { public void onClick(View v) { if (null == media_engine_) return; if (media_engine_.is_rtmp_stream_running()) { media_engine_.stop_rtmp_stream(); button_rtmp_publisher_.setText("开始RTMP推送"); text_view_rtmp_url_.setText("RTMP URL: "); Log.i(TAG, "stop rtmp stream"); return; } if (!media_engine_.is_video_capture_running()) return; String rtmp_url; if (input_rtmp_url_ != null && input_rtmp_url_.length() > 1) { rtmp_url = input_rtmp_url_; Log.i(TAG, "start, input rtmp url:" + rtmp_url); } else { rtmp_url = baseURL + String.valueOf((int) (System.currentTimeMillis() % 1000000)); Log.i(TAG, "start, generate random url:" + rtmp_url); } media_engine_.set_fps(fps_); media_engine_.set_gop(gop_); media_engine_.set_video_encoder_type(video_encoder_type); if (!media_engine_.start_rtmp_stream(rtmp_url)) return; button_rtmp_publisher_.setText("停止RTMP推送"); text_view_rtmp_url_.setText("RTMP URL:" + rtmp_url); Log.i(TAG, "RTMP URL:" + rtmp_url); } }
启动RTSP服务:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ private class ButtonRTSPServiceListener implements OnClickListener { public void onClick(View v) { if (null == media_engine_) return; if (media_engine_.is_rtsp_server_running()) { media_engine_.stop_rtsp_stream(); media_engine_.stop_rtsp_server(); button_rtsp_publisher_.setText("启动RTSP流"); button_rtsp_service_.setText("启动RTSP服务"); text_view_rtsp_url_.setText("RTSP URL:"); return; } if (!media_engine_.start_rtsp_server(rtsp_port_, null, null)) return; button_rtsp_service_.setText("停止RTSP服务"); } }
发布RTSP流:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ private class ButtonRtspPublisherListener implements OnClickListener { public void onClick(View v) { if (null == media_engine_) return; if (media_engine_.is_rtsp_stream_running()) { media_engine_.stop_rtsp_stream(); button_rtsp_publisher_.setText("启动RTSP流"); text_view_rtsp_url_.setText("RTSP URL:"); return; } if (!media_engine_.is_video_capture_running()) return; media_engine_.set_fps(fps_); media_engine_.set_gop(gop_); media_engine_.set_video_encoder_type(video_encoder_type); if (!media_engine_.start_rtsp_stream("stream1")) return; button_rtsp_publisher_.setText("停止RTSP流"); } }
RTSP流发布成功后,底层会把RTSP拉流的URL回调上来:
/* * MainActivity.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ public void on_nt_rtsp_stream_url(String url) { Log.i(TAG, "on_nt_rtsp_stream_url: " + url); MainActivity activity = get_activity(); if (activity != null) { activity.runOnUiThread(new Runnable() { MainActivity activity_; String url_; public void run() { activity_.text_view_rtsp_url_.setText("RTSP URL:" + url_); } public Runnable set(MainActivity activity, String url) { this.activity_ = activity; this.url_ = url; return this; } }.set(activity, url)); } }
可以看到,上述操作,都是在MainActivity.java调用的,如果是需要做demo版本集成,只需要关注MainActivity.java的业务逻辑即可,为了便于开发者对接,我们做了接口的二次封装,除了常规的RTMP推送、轻量级RTSP服务设计外,如果需要录像,只要在MainActivity.java调用这里的接口逻辑即可,非常方便:
/* * NTStreamMediaEngine.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ package com.daniulive.smartpublisher; public interface NTStreamMediaEngine { void register_callback(Callback callback); void unregister_callback(Callback callback); void set_resolution_level(int level); int get_resolution_level(); /* * 启动媒体投影 */ boolean start_video_capture(int token_code, android.content.Intent token_data); boolean is_video_capture_running(); void stop_video_capture(); /* * 启动麦克风 */ boolean start_audio_record(int sample_rate, int channels); boolean is_audio_record_running(); void stop_audio_record(); /* * Android 10及以上支持, Android10以下设备调用直接返回false * 需要有RECORD_AUDIO权限 * 要开启媒体投影 */ boolean start_audio_playback_capture(int sample_rate, int channels); boolean is_audio_playback_capture_running(); void stop_audio_playback_capture(); /* * 输出的音频类型 * 0: 不输出音频 * 1: 输出麦克风 * 2: 输出audio playback(Android 10及以上支持) */ boolean set_audio_output_type(int type); int get_audio_output_type(); void set_fps(int fps); void set_gop(int gop); boolean set_video_encoder_type(int video_encoder_type); int get_video_encoder_type(); /* * 推送RTMP */ boolean start_rtmp_stream(String url); boolean is_rtmp_stream_running(); String get_rtmp_stream_url(); void stop_rtmp_stream(); /* * 启动RTSP Server, 需要设置端口,用户名和密码可选 */ boolean start_rtsp_server(int port, String user_name, String password); boolean is_rtsp_server_running(); void stop_rtsp_server(); /* * 发布RTSP流 */ boolean start_rtsp_stream(String stream_name); boolean is_rtsp_stream_running(); String get_rtsp_stream_url(); void stop_rtsp_stream(); /* * 启动本地录像 */ boolean start_stream_record(String record_directory, int file_max_size); boolean is_stream_recording(); void stop_stream_record(); boolean is_stream_running(); interface Callback { void on_nt_video_capture_stop(); void on_nt_rtsp_stream_url(String url); } }
如果对音视频这块相对了解的开发者,可以继续到NTStreamMediaProjectionEngineImpl.java文件,查看或修改相关的技术实现:
/* * NTStreamMediaProjectionEngineImpl.java * Created by daniusdk.com on 2017/04/19. * WeChat: xinsheng120 */ package com.daniulive.smartpublisher; import android.app.Activity; import android.app.Application; import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Point; import android.graphics.Rect; import android.media.Image; import android.media.projection.MediaProjection; import android.media.projection.MediaProjectionManager; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.Process; import android.util.Log; import android.util.Size; import android.view.Surface; import android.view.WindowManager; import android.view.WindowMetrics; import com.eventhandle.NTSmartEventCallbackV2; import com.eventhandle.NTSmartEventID; import com.voiceengine.NTAudioRecordV2; import com.voiceengine.NTAudioRecordV2Callback; import com.videoengine.NTMediaProjectionCapture; import com.voiceengine.NTAudioPlaybackCapture; import java.lang.ref.WeakReference; import java.nio.ByteBuffer; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; public class NTStreamMediaProjectionEngineImpl implements AutoCloseable, NTStreamMediaEngine, NTVirtualDisplaySurfaceSinker.Callback, NTMediaProjectionCapture.Callback { private static final String TAG = "NTLogProjectionEngine"; private static final Size DEFAULT_SIZE = new Size(1920, 1080); public static final int RESOLUTION_LOW = 0; public static final int RESOLUTION_MEDIUM = 1; public static final int RESOLUTION_HIGH = 2; private final Application application_; private final long image_thread_id_; private final long running_thread_id_; private final Handler image_handler_; private final Handler running_handler_; private final WindowManager window_manager_; private final MediaProjectionManager projection_manager_; private int screen_density_dpi_ = android.util.DisplayMetrics.DENSITY_DEFAULT; private final SmartPublisherJniV2 lib_publisher_; private final LibPublisherWrapper.RTSPServer rtsp_server_; private final LibPublisherWrapper stream_publisher_; private final CopyOnWriteArrayList<NTStreamMediaEngine.Callback> callbacks_ = new CopyOnWriteArrayList<>(); private final AtomicReference<VideoSinkerCapturePair> video_capture_pair_ = new AtomicReference<>(); private final AudioRecordCallbackImpl audio_record_callback_; private final AudioPlaybackCaptureCallbackImpl audio_playback_capture_callback_; private final AtomicReference<NTAudioRecordV2> audio_record_ = new AtomicReference<>(); private final AtomicReference<NTAudioPlaybackCapture> audio_playback_capture_ = new AtomicReference<>(); ... }
以Android平台RTMP推送模块为例,我们主要实现了如下功能:
- 音频编码:AAC/SPEEX;
- 视频编码:H.264、H.265;
- 推流协议:RTMP;
- [音视频]支持纯音频/纯视频/音视频推送;
- [摄像头]支持采集过程中,前后摄像头实时切换;
- 支持帧率、关键帧间隔(GOP)、码率(bit-rate)设置;
- 支持RTMP推送 live|record模式设置;
- 支持前置摄像头镜像设置;
- 支持软编码、特定机型硬编码;
- 支持横屏、竖屏推送;
- 支持Android屏幕采集推送;
- 支持自建标准RTMP服务器或CDN;
- 支持断网自动重连、网络状态回调;
- 支持实时动态水印;
- 支持实时快照;
- 支持降噪处理、自动增益控制;
- 支持外部编码前音视频数据对接;
- 支持外部编码后音视频数据对接;
- 支持RTMP扩展H.265(需设备支持H.265特定机型硬编码)和Enhanced RTMP;
- 支持实时音量调节;
- 支持扩展录像模块;
- 支持Unity接口;
- 支持H.264扩展SEI发送模块;
- 支持Android 5.1及以上版本。
轻量级RTSP服务,在上述非RTMP协议依赖的基础上,增加了如下功能:
- [音频格式]AAC;
- [视频格式]H.264、H.265;
- [协议类型]RTSP;
- [传输模式]支持单播和组播模式;
- [端口设置]支持RTSP端口设置;
- [鉴权设置]支持RTSP鉴权用户名、密码设置;
- [获取session连接数]支持获取当前RTSP服务会话连接数;
- [多服务支持]支持同时创建多个内置RTSP服务;
- [RTSP url回调]支持设置后的rtsp url通过event回调到上层。
总结
以上是Android平台屏幕采集、音频播放声音采集、麦克风采集编码打包推送到RTMP和轻量级RTSP服务的相关技术实现,做成高稳定低延迟的同屏系统,还需要有配套好的RTMP、RTSP直播播放器,整体部署,内网大并发环境下,还需要考虑到如何组网等诸多因素。做demo容易,做个成熟的模块还是有一定的难度,以上抛砖引玉,感兴趣的开发者,可以单独跟我沟通探讨。