局域网下,实现一键共享屏幕到移动设备

1. 问题起因

开发需求

刚不久开发一款了教育类app,需要实现教师端对学生移动设备进行远程操控,比如对学生平板进行解锁屏,共享电脑屏幕到学生端,监控学生屏幕内容等。

网络环境

教师端网线或WIFI接入,iPad和Android Pad通过WIFI接入,确保在一个网段下。

大致功能
graph TB S(Service<br/>教师端) S--一键解锁/锁定屏幕-->C1 S--一键分发文件<br/>ppt/doc/img-->C2
S--屏幕广播-->C3 S--学生抢答-->C4 S--实时监控-->C5 C1(Client1 <br/>iPad/Android Pad)
C2(Client2 <br/>iPad/Android Pad) C3(Client3 <br/>iPad/Android Pad) C4(Client4
<br/>iPad/Android Pad) C5(Client4 <br/>iPad/Android Pad)
2. 实现方案

教师端采用FFmpeg <https://ffmpeg.org/about.html>采集屏幕音视频,iOS、Android端使用ijkplayer
<https://github.com/bilibili/ijkplayer>拉流播放,流传输协议采用RTMP协议,通讯方式采用TCP Socket。

通讯实现

局域网内教师端充当服务器发送UDP广播(内容包含本机IP和端口号),iOS、Android端收到UDP广播获取到IP地址和端口后,采用Socket【
CocoaAsyncSocket <https://github.com/robbiehanson/CocoaAsyncSocket>
(iOS)、Socket(Android)】与教师端进行TCP连接,建立连接完成后,通过Socket收发消息进行通讯。

3. 技术模块

3.1 Mac下nginx-full搭建

1.Homebrew安装 <https://brew.sh>
/usr/bin/ruby -e "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/master/install)"
2.安装nginx-full(rtmp)
brew install nginx-full --with-rtmp-module
3.查看nginx安装的路径等信息
brew info nginx-full
会显示出配置文件所在路径
The default port has been set in /usr/local/etc/nginx/nginx.conf to 8080 so
that nginx can run without sudo.
4.配置nginx.conf,文件最后空白处直接添加(application live,live随便起名,之后推流对应就可以了)。
rtmp { server { listen 1935; application live { live on; record off; } } }
5.修改保存后重启nginx
nginx -s reload
3.2 安装ffmepg推流

1.安装
brew install ffmpeg
2.推送屏幕流
ffmpeg -f avfoundation -pixel_format uyvy422 -i "1" -f flv
rtmp://localhost:1935/live

执行后显示Output地址,rtmp://localhost:1935/live,也就是本机ip,比如rtmp://192.168.1.2:1935/live,Mac电脑可以安装VLC播放器,测试播放。
Output #0, flv, to 'rtmp://localhost:1935/live': Metadata: encoder :
Lavf58.20.100 Stream #0:0: Video: flv1 (flv) ([2][0][0][0] / 0x0002), yuv420p,
2560x1600, q=2-31, 200 kb/s, 1000k fps, 1k tbn, 1000k tbc Metadata: encoder :
Lavc58.35.100 flv Side data: cpb: bitrate max/min/avg: 0/0/200000 buffer size:
0 vbv_delay: -1 frame= 241 fps= 27 q=24.8 size= 5368kB time=00:00:08.86
bitrate=4958.5kbits/s speed= 1x
3.3 IJKPlayer编译

附件:iOS编译后动态库和Android库文件 <https://github.com/superxjhw/Document>

参考ijkplayer <https://github.com/bilibili/ijkplayer>
文档说明,在mac下编译即可,不过在编译之前,需要修改一些配置文件。如果想到达首屏秒开,务必看完这些内容再去编译,包括后面讲到的客户端首屏秒开
,因为涉及C文件修改,省去之后又要重新编译。

编译之前一定仔细阅读README.md,比如在编译环境和所需文件:
# install homebrew, git, yasm ruby -e "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/master/install)" brew
install git brew install yasm # add these lines to your ~/.bash_profile or
~/.profile # export ANDROID_SDK=<your sdk path> # export ANDROID_NDK=<your ndk
path> # on Cygwin (unmaintained) # install git, make, yasm
还有就是他当时的编译环境My Build
Environment,这块需要说明一下,尤其是编译安卓的,NDK就直接用r10e,虽然之后的也可以,但是会有编译失败的可能,因为我编译的时候就失败了,更换为作者使用的版本通过。
Common Mac OS X 10.11.5 Android NDK r10e Android Studio 2.1.3 Gradle 2.14.1
iOS Xcode 7.3 (7D175) HomeBrew ruby -e "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/master/install)" brew
install git
README.md 对应有Build iOS和Build Android,编译哪个平台就执行对应的命令。其中默认链接的脚本是 less
codec/format for smaller binary size,具体说明看文档,这里我选择的默认配置。

Build iOS编译中,./init-ios.sh命令久一点,中间要下载一些东西,具体内容可以查看脚本文件。比如== pull ffmpeg base
==,明显要好久,除非你当时下载的速度很快。
== pull ffmpeg base == Cloning into 'extra/ffmpeg'... remote: Enumerating
objects: 538907, done. Receiving objects: 19% (103984/538907), 30.82 MiB |
42.00 KiB/s
iOS 编译可能会遇到的问题和解决办法

问题一:
./libavutil/arm/asm.S:50:9: error: unknown directive .arch armv7-a ^ make: ***
[libavcodec/arm/aacpsdsp_neon.o] Error 1 make: *** Waiting for unfinished
jobs....
解决办法:

修改./compile-ffmpeg.sh文件

将这一行:FF_ALL_ARCHS_IOS8_SDK="armv7 arm64 i386 x86_64"

修改为:FF_ALL_ARCHS_IOS8_SDK="arm64 i386 x86_64"

问题二:
'openssl/ssl.h' file not found #include <openssl/ssl.h> ERROR: openssl not
found
解决办法:

编译ffmpeg软解码库,这个过程会生成各种架构的ffmpeg,编译ffmpeg前要先compile
OpenSSL,对openssl进行编译,如果未执行可能会报错。必须先执行./compile-openssl.sh all

实际编译的确会遇到这些问题,尤其是问题一。

这些问题参考了博客iOS IJKPlayer 项目集成 <https://www.jianshu.com/p/45c49db8c538>

3.4 iOS Framwork合并

一切顺利完成后运行demo,编译获取动态库,这边我直接使用真机和模拟器合并的动态库,当然你也可以不要合并,直接使用真机动态库。

1.配置Release模式,Edit Scheme —> Run —> info —> Build Configuration —> Release

2.真机和模拟器各自编译

3.Products —> IJKMediaFramework.framework —> Show in Finder

4.终端 cd Products 目录下,执行: lipo -create 真机 模拟器 -output 合并文件
lipo -create Release-iphoneos/IJKMediaFramework.framework/IJKMediaFramework
Release-iphonesimulator/IJKMediaFramework.framework/IJKMediaFramework -output
IJKMediaFramework
5.合并后的文件替换掉真机framework下的文件,新的IJKMediaFramework.framework就是合并后的动态库,直接拖拽到项目使用

我的Xcode版本 Version 10.3 (10G8)

附件:iOS编译后动态库和Android库文件 <https://github.com/superxjhw/Document>

3.5 客户端首屏秒开

首屏秒开,需要结合视频清晰度和延迟,采取合适的帧率。客户端取消缓存也可以减少首个关键帧显示时间。具体参考首屏秒开和追幀播放技术
<https://cloud.baidu.com/doc/LSS/s/djwvyyao9/>。

附上iOS和Android对IJKPlayer设置。

iOS端:
- (IJKFFOptions *)options { if (!_options) { IJKFFOptions *options =
[IJKFFOptions optionsByDefault]; // Set param [options
setFormatOptionIntValue:1024 * 16 forKey:@"probsize"]; [options
setFormatOptionIntValue:50000 forKey:@"analyzeduration"]; [options
setPlayerOptionIntValue:0 forKey:@"videotoolbox"]; [options
setCodecOptionIntValue:IJK_AVDISCARD_DEFAULT forKey:@"skip_loop_filter"];
[options setCodecOptionIntValue:IJK_AVDISCARD_DEFAULT forKey:@"skip_frame"];
[options setPlayerOptionIntValue:1000 forKey:@"max_cached_duration"]; [options
setPlayerOptionIntValue:1 forKey:@"infbuf"]; // 无限读 [options
setPlayerOptionIntValue:0 forKey:@"packet-buffering"]; _options = options; }
return _options; }
Android端:
// 设置播放前的最大探测时间 ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT,
"analyzemaxduration", 100L);
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize",
10240L); // 每处理一个packet之后刷新io上下文
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "flush_packets",
1L); // 是否开启预缓冲,一般直播项目会开启,达到秒开的效果,不过带来了播放丢帧卡顿的体验
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER,
"packet-buffering", 0L); // 放前的探测Size,默认是1M, 改小一点会出画面更快
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "probsize", 200);
// 设置播放前的探测时间 1,达到首屏秒开效果
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration",
1); ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER,
"max_cached_duration", 1000);
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1); //
无限读 ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER,
"max-buffer-size", 0); // 不额外优化(使能非规范兼容优化,默认值0 )
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "fast", 1); //
缩短播放的rtmp视频延迟在1s内 ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT,
"fflags", "nobuffer"); // 如果是rtsp协议,可以优先用tcp(默认是用udp)
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "rtmp_transport",
"tcp"); // 支持硬解 1:开启 O:关闭
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-hevc",
1); // 跳帧处理,放CPU处理较慢时,进行跳帧处理,保证播放流程,画面和声音同步
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "rtsp_transport",
"tcp"); ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER,
"framedrop", 1L); ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER,
"start-on-prepared", 1);
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT,
"http-detect-range-support", 0);
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_loop_filter",
48L); ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_frame",
0); // 因为项目中多次调用播放器,有网络视频,resp,本地视频,还有wifi上http视频,所以得清空DNS才能播放WIFI上的视频
ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "dns_cache_clear",
1);
编译之前修改f_ffplay.c,该方法明显提高了首屏延迟问题

路径 ijkmedia—> ijkplayer —> ff_ffplay.c

第一个修改的地方:double vp_duration 方法

将此代码
static double vp_duration(VideoState *is, Frame *vp, Frame *nextvp) { if
(vp->serial == nextvp->serial) { double duration = nextvp->pts - vp->pts; if
(isnan(duration) || duration <= 0 || duration > is->max_frame_duration) return
vp->duration; else return duration; } else { return 0.0; } }
替换为一下代码
static double vp_duration(VideoState *is, Frame *vp, Frame *nextvp) { return
vp->duration; }
第二个修改的地方:static int ffplay_video_thread(void *arg) 方法

注释掉下面这一行代码
AVRational frame_rate = av_guess_frame_rate(is->ic, is->video_st, NULL);
将下面这行代码
duration = (frame_rate.num && frame_rate.den ?
av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
修改为
duration = 0.01;
更改后如下
static int ffplay_video_thread(void *arg) { FFPlayer *ffp = arg; VideoState
*is = ffp->is; AVFrame *frame = av_frame_alloc(); double pts; double duration;
int ret; AVRational tb = is->video_st->time_base; // 注释掉 // AVRational
frame_rate = av_guess_frame_rate(is->ic, is->video_st, NULL); int64_t dst_pts =
-1; int64_t last_dst_pts = -1; int retry_convert_image = 0; int
convert_frame_count = 0; // ···此处省略很多代码 #endif // 这行代码直接修改为 duration = 0.01; //
duration = (frame_rate.num && frame_rate.den ?
av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0); duration = 0.01; pts
= (frame->pts == AV_NOPTS_VALUE) ? NAN : frame->pts * av_q2d(tb); ret =
queue_picture(ffp, frame, pts, duration, frame->pkt_pos,
is->viddec.pkt_serial); av_frame_unref(frame); #if CONFIG_AVFILTER } #endif if
(ret < 0) goto the_end; } the_end: #if CONFIG_AVFILTER
avfilter_graph_free(&graph); #endif av_log(NULL, AV_LOG_INFO, "convert image
convert_frame_count = %d\n", convert_frame_count); av_frame_free(&frame);
return 0; }
修改f_ffplay.c参考了博客ijkplayer的一些问题优化记录
<https://blog.csdn.net/hejjunlin/article/details/57075026>