一种Android设备连接手机H5展示实时画面的方案

需求背景

最近在做一个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
}

后续:一种Android设备连接手机H5展示实时画面的方案(二)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值