Android平台如何采集屏幕数据并推送RTMP服务器实现无纸化同屏?
如何获取屏幕数据?
Android平台实现无纸化同屏,屏幕采集是关键,也是源头,Android同屏技术允许将Android设备的屏幕内容实时传输并显示在其他设备上。实现方式主要有基于系统自带功能(如某些Android设备支持的无线投屏到智能电视)以及通过开发自定义应用实现。在自定义应用开发中,通常需要借助Android的MediaProjection API 来获取屏幕内容的图像数据,该API提供了屏幕捕获的能力,通过创建MediaProjectionManager实例,发起屏幕捕获请求,获取到MediaProjection对象后,就可以进一步创建虚拟显示,从而获取屏幕的图像流。
一些小的tips
我们理解的Android平台RTMP同屏,采集到数据后,无非就是实现软、硬编码,然后打包发送到RTMP服务器,播放端拉流播放即可,实际上,几乎每一步操作,都可以考虑精细化的设计和处理,实现期望的高稳定、低延迟和资源占用体验。
先说我们做的Android平台RTMP推送模块的功能设计吧:
Android平台RTMP直播推送SDK
- 音频编码: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及以上版本。
从数据采集开始,我们就需要考虑用怎样的方式最高效?
数据拿到后,如果分辨率过高,要不要做缩放?
如果屏幕不动,数据帧不回调上来,要不要补帧?
到底是软编码还是硬编码?
走264还是265编码?
是不是可以同时采集摄像头?
如果采集摄像头,能不能用camera2采集?
采集到的camera2数据,如何做数据编码和打包传输?
要不要加动态文字、图片水印?
要不要做音频采集,比如支持麦克风或扬声器采集?亦或二者均采集?
要不要本地录像?
起播慢怎么办?
帧率、码率怎么动态配置?
要不要做实时快照?
要不要支持编码前的其他音视频数据类型对接?
要不要支持编码后的音视频数据对接?
音视频采集有时间偏差怎么办?
要不要支持RTMP HEVC?
如果需要采集Unity camera场景怎么办?
什么都做好了,延迟还是高,到底怎么回事?
单个无纸化会议场景,设备数非常多怎么办。。。
技术实现
做了这么多假设,我们以大牛直播SDK的Android的SmartServicePublisherV2的同屏demo为例,介绍下相关的技术实现细节。
启动APP后,先选择需要采集的分辨率(如果选原始分辨率,系统不做缩放),然后选择“启动媒体投影”,并分别启动音频播放采集、采集麦克风。如果音频播放采集和采集麦克风都打开,可以通过右侧下拉框,推送过程中,音频播放采集和麦克风采集实时切换。需要注意的是,Android采集音频播放的audio,音频播放采集是依赖屏幕投影的,屏幕投影关闭后,音频播放也就采不到了。
采集到的屏幕数据,特别是高分屏的话,我们可以缩放后再推送。如果对画质和分辨率要求比较高,可以选择原始分辨率。设备支持硬编码,优先选择H.264硬编,如果是H.265硬编,需要RTMP服务器支持扩展H.265(或Enhanced RTMP)。都选择好后,设置RTMP推送的URL,点开始RTMP推送按钮即可。
废话不多说,上代码,启动媒体服务,进入系统后,我们会自动启动媒体服务:
/*
* 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);
}
@Override
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 {
@Override
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);
}
}