需求背景
最近在做一个toB项目,有一台自研的Android 10设备不断拍摄视频,但设备在实际运营时,存在各种异常场景,如镜头被遮挡或者位姿偏移(比如拍天拍地等)的情况。由于设备本身限制,没有任何操作界面,当设备被遮挡或安装姿态异常时,用户无法直接看到屏幕画面。
期望可以支持用户通过自有手机直接连接设备,实时观看设备拍摄画面,方便调整设备安装姿态,减少运营成本。
设计思路
用户手机向设备发送建连请求,建连成功后。设备端在“主摄App”中开启服务,将拍摄到的影像数据,持续输送给用户手机。手机接收到数据后,解析并展示,实现实时观看功能。
设计时需要考虑以下几个点:
- 手机和设备通过什么方式建连并传输数据?蓝牙、局域网Wi-Fi、还是通过服务器中转?
- 手机端如何操作并展示影像?App还是H5?
- 数据传输格式怎么定,视频流还是图片?传输的稳定性和效率如何,会不会卡顿或延迟?
手机端的展示载体
经分析决定,在手机端以H5为载体实现整体功能。
- 用户手机上都已安装项目“自研App”,我们团队只负责其中的H5页面,现有很多用户能力都以H5页面方式内嵌其中。添加新的H5页面承载整个需求,对用户来说学习成本较低,体验也更流畅。
- 新建App的方式,需要额外考虑App的发布策略(涉及打包、上线、安全等环节),同时根据用户手机不同,还需要实现Android和iOS两套逻辑(团队没有iOS开发),从时间和复杂度上,比H5方案花费都要高。从用户角度考虑,新装App处理设备遮挡,和原有“自研App”H5页面存在体验割裂,整体体感不是很好。Pass。
但也要注意到,基于H5自身的原因,该方案存在一些限制:
- H5的整体性能不如App,图像展示的流畅度和清晰度较低。
- H5 socket通信只支持WebSokcet。
- 受限于现有的技术框架,我们团队只负责其中H5页面,native代码由另一团队负责。如果使用jsbridge能力,沟通成本太大。因此H5只能使用纯js代码,无法使用js调起原生能力,部分功能受限(后续阐述)。
连接方式
调研后发现,
- 参考市面监控摄像头的技术思路,通过服务器中转,手机和设备之间不需要直接连通,但此方案还需要额外增加服务端的开发成本(产品期望客户端自己解决)。Pass。
- 两端通过蓝牙直连,在调研时发现,蓝牙连接需要在设备端手动添加配对,对于没有操作界面的设备来说,实现难度太大。而且H5实现蓝牙传输数据,需要依赖Web Bluetooth API技术。目前,Web Bluetooth API仅支持最新版本的Chrome、Edge和Opera浏览器,而且需要在HTTPS站点上运行。我们无法保证用户手机浏览器支持Web Bluetooth API技术。另外,蓝牙传输从效率和稳定性上也不如局域网连接。Pass。
- 两端通过局域网Wi-Fi连接,更有利于实际的业务场景。
基于局域网连接的初步设计,进一步考虑的就是双端连接哪个局域网。
- 连三方局域网或手机开热点,需要用户手机连上局域网或打开热点,在H5页面把Wi-Fi SSID和密码发给设备,目前流行的方案是发送声波(设备没有麦克风不支持)或配网指令(H5不支持发送,需要借助原生能力),实际场景均受限制,而且设备是否连接成功对于用户也不可见。若连接成功,何时断开连接也需要考虑,同时担心连接不明三方网络时,对核心拍照流程的影响不好估计。Pass。
- 设备开热点连接,从操作上来说对用户更友好,用户手机正常连接设备热点即可。设备一直走自己的网络,对原有流程也不会有影响。
综上所述,最终方案为两端通过局域网Wi-Fi连接,设备开热点,同时开启WebSocket服务。用户手机连接设备热点。H5界面发起WebSocket连接。考虑流量消耗,设备开启热点后要支持定时关闭能力。
影像传输方案
目前设备系统本身以1s30帧的频率输出1080*720的Yuv图像,“主摄App”接收到系统输出图像后,15帧抽1帧,即以1s2帧的频率将Yuv图像压缩转化为1080*720的Jpeg图像,存入本地。
- 如果使用视频流方式,需要占用摄像头,当前拍摄主流程会中断,影响业务核心流程。且视频流方式采用WebRTC技术,需要服务端支持。目前需求暂不需要展示视频,Pass。
- 只要传输足够帧率的图片,在H5上连续展示,可以达到低配的实时影像,符合本次需求。
经调研,如果将原始Yuv图像给手机H5,H5需要将Yuv图像转为Jpeg图像。由于js未提供相关接口,需要手写转化代码,况且Yuv图像比Jpeg图像数据量大得多,不如设备端转化后再传输效率更高。
另经测试,如果以1s30帧的原始频率传输数据,由于频率太高,手机H5接收后再转回图像展示的处理速度跟不上,视觉上会有明显的延迟;而以1s2帧的频率传输数据,又会有很明显的卡顿。测试发现1s15帧的频率效果最好。
同时考虑传输稳定性,和手机H5的展示需求,可以将1080*720的图片再压缩宽高。
另,在设备上,一张图片从Yuv转成Jpeg,再压缩,处理速度至少40多ms,也限制了传输频率不能太高。
最终确认,以1s15帧的频率,传输270*180或180*120的压缩后的Jpeg图像,效果最佳。
相关代码
设备打开网络热点
import android.net.ConnectivityManager;
import android.net.wifi.WifiConfiguration;
import android.net.wifi.WifiManager;
import android.os.ResultReceiver;
import android.provider.Settings;
Settings.Global.putInt(InspectionApplication.getContext().getContentResolver(), "soft_ap_timeout_enabled", false ? 1 : 0);
WifiConfiguration wifiConfig = new WifiConfiguration();
WifiManager wifiManager = (WifiManager) InspectionApplication.getContext().getSystemService(Context.WIFI_SERVICE);
String deviceId = InspectionApplication.getDeviceId();
String substring = deviceId.substring(deviceId.length() - 5);
String SSID = "GDTB_" + substring;
Log.i(TAG, "startTethering SSID " + SSID);
wifiConfig.SSID = SSID; // 热点名称
wifiConfig.allowedKeyManagement.set(4);
wifiConfig.preSharedKey = "autonavi"; // 热点密码
//wifiConfig.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK); 加密方式
wifiConfig.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN);
Log.i(TAG, "startTethering wifiConfig " + wifiConfig);
Method method = wifiManager.getClass().getDeclaredMethod("setWifiApConfiguration", WifiConfiguration.class);
method.setAccessible(true);
method.invoke(wifiManager, wifiConfig);
ResultReceiver resultReceiver = new ResultReceiver(handler) {
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
ALog.e(TAG, " resultCode " + resultCode + " resultData " + resultData);
super.onReceiveResult(resultCode, resultData);
}
};
ConnectivityManager connectivityManager =
(ConnectivityManager) InspectionApplication.getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
Class cm = Class.forName("android.net.ConnectivityManager");
Field mService = cm.getDeclaredField("mService");
mService.setAccessible(true);
Object o = mService.get(connectivityManager);
Class<?> aClass = Class.forName("android.net.IConnectivityManager");
Method startTethering = aClass.getDeclaredMethod("startTethering", int.class, ResultReceiver.class, boolean.class, String.class);
startTethering.setAccessible(true);
String pkgName = InspectionApplication.getContext().getOpPackageName();
startTethering.invoke(o, 0, resultReceiver, false, pkgName);
设备关闭网络热点
ConnectivityManager cm = (ConnectivityManager) InspectionApplication.getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
Method stopTethering = cm.getClass().getDeclaredMethod("stopTethering", int.class);
stopTethering.invoke(cm, 0);
获取设备热点状态
public boolean getApRun() {
WifiManager wifiManager = (WifiManager) InspectionApplication.getContext().getApplicationContext().getSystemService(Context.WIFI_SERVICE);
try {
Method getWifiApState = wifiManager.getClass().getMethod("getWifiApState");
//调用getWifiApState() ,获取返回值
int state = (int) getWifiApState.invoke(wifiManager);
//通过放射获取 WIFI_AP的开启状态属性
Field field = wifiManager.getClass().getDeclaredField("WIFI_AP_STATE_ENABLED");
//获取属性值
int value = (int) field.get(wifiManager);
//判断是否开启
Log.i(TAG, "getApRun state " + state + " value " + value);
if (state == value) {
return true;
} else {
return false;
}
} catch (Exception e) {
Log.e(TAG, e);
}
return false;
}
设备端开启WebSocket服务
import org.java_websocket.WebSocket
import org.java_websocket.WebSocketImpl
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.Inet4Address
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.SocketException
/**
* @author zhoucj
* @since 2023/7/7
*/
class WifiSocketManager {
private lateinit var serverSocket: ServerSocket
override fun start() {
// websocket连接
Log.i(TAG, "Start ServerSocket...")
WebSocketImpl.DEBUG = false;
try {
serverSocket = ServerSocket(8087)
serverSocket.start()
} catch (e: Exception) {
Log.e(TAG, e)
}
}
inner class ServerSocket(port: Int) : WebSocketServer(InetSocketAddress(port)) {
var connect: WebSocket? = null
override fun onOpen(conn: WebSocket?, handshake: ClientHandshake?) {
Log.i(TAG, "Some one Connected...")
connect = conn
}
override fun onClose(conn: WebSocket?, code: Int, reason: String?, remote: Boolean) {
}
override fun onMessage(conn: WebSocket?, message: String?) {
Log.i(TAG, "OnMessage:" + message.toString())
if ("close".equals(message)) {
ALog.i(TAG, "close connectd")
connect = null
}
}
override fun onError(conn: WebSocket?, ex: Exception?) {
Log.e(TAG, ex.toString());
}
override fun onStart() {
}
}
}
Yuv图像转为Jpeg图像并压缩
val yuvImage = YuvImage(yuvData, ImageFormat.NV21, width, height, null)
val bos = ByteArrayOutputStream()
yuvImage.compressToJpeg(Rect(0, 0, width, height), CloudConfigUtils.getQuality(), bos)
val jpegData = bos.toByteArray()
Log.i(TAG, "jpegData size: ${jpegData.size}")
bos.close()
// 缩放
val originalBitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size)
val resizedBitmap = Bitmap.createScaledBitmap(originalBitmap, width / 6, height / 6, true)
val stream = ByteArrayOutputStream()
resizedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, stream)
val message = stream.toByteArray()
Log.i(TAG, "resized size: ${message.size}")
socketManager?.sendMessage(message)
stream.close()
H5连接WebSocket
openWebSocket() {
// websocket连接
this.ws = new WebSocket('ws://192.168.174.108:8087')
console.log(this.ws)
this.ws.onopen = () => {
this.status = 'Connected'
console.log(this.status)
}
this.ws.onmessage = (event) => {
console.log(event.data)
// 接收图像数据
this.updateImage(event.data)
}
this.ws.onclose = () => {
this.status = 'Not Connected'
console.log(this.status)
this.ws = null
}
},
H5解析图像数据并展示
<image class='center_image' id='center_image' mode='aspectFit' :src="imageSrc"/>
updateImage(blob) {
var reader = new FileReader();
var img = document.getElementById('center_image')
let fun = this.setImageSrc;
reader.onload = function() {
var dataUrl = reader.result;
// 解决闪烁问题,先将图片加载到临时控件上,加载完成后再赋给img
var tempImg = document.createElement('img');
tempImg.onload = function() {
fun(tempImg.src)
};
tempImg.src = dataUrl;
};
reader.readAsDataURL(blob);
},
setImageSrc(dataUrl) {
this.imageSrc = dataUrl
}