h5与原生的大文件交互解决策略

起因

最近项目有个新的需求,就是社区要实现本地的视频录制,预览,上传然后社区播放的功能,由于之前项目重构的时候,为了节省时间 整个社区的功能都交由h5开发,现在要添加本地视频,然后预览播放有两种实现方式:

  • 第一种方式是视频的选择,预览,都是直接跳转原生界面进行操作,h5只负责界面的展示
  • 第二种方式是视频的展示,预览和播放都由h5统一做,原生只负责视频的选择然后把数据传递给h5

我个人是倾向于第一种实现方式的,因为h5本身存在许多兼容性问题,再加上Android本身被各个国产厂商魔改,而且也有h5也有性能问题,h5播放视频也会有卡顿,全屏,手机硬件交互等等问题,总之我是很不推荐第二种方案的。但但但是呢,我们的产品说微博,微信h5播放视频不是挺好的么,视频播放那么流畅,也没有什么卡顿,兼容性问题呢。这我还能说什么呢,产品都坚持了,而且两周一个迭代周期也不允许我有什么可多说的了,只好按照产品的意愿做了撒,况且视频播放器那一套流程,各种状态的控制也挺复杂的,这些问题都交由h5开发的同学去考虑吧。

方案调研

在确定了由h5进行视频的展示,预览,播放后,就开始寻找我们如何把视频数据传递给h5,按照项目里面已有的h5与原生的交互方案,使用JSBridge进行数据交互,但是数据有大小限制,视频动不动就几十M,即使将视频压缩,也还是不能完全保证能够把数据传递过去;然后就是使用webview的input_file机制,调起系统的文件选择器,选择文件,但是缺点是每次只能选择一个文件,而且界面UI不能自定义,这个方案也被放弃了;然后又尝试了把文件转化为二进制流传递给h5,但是发现h5根本无法识别,这种方案又失败了;最后在网上找到一种似乎可行的方案,那就是在本地启动一个本地服务器,然后定义一个文件请求接口,h5调起原生的视频选择控件选择一个视频后,我们把文件路径和本地服务器地址传给h5,然后h5点击预览时就通过这个接口把文件路径传给我,本地服务器在收到请求后,根据文件路径找到文件,然后把文件流返给h5,h5在拿到文件流后进行视频的播放。这种方案看来可行,而且也有成功的案例,比如局域网投屏和文件传输都是用的这种方案。

Demo实现

在确定了使用本地服务器实现文件传输方案后,就要着手实现了。由于之前没有做过而且项目时间比较紧,就在全球最大的同性交友网站上找寻找看有没有比较优秀的开源库,最后找到了几个比较好的三方库,经过比较选择了AndServer,根据说明文档就开始开发。首先,通过gradle方式引进项目

dependencies {
    .........
    implementation 'com.yanzhenjie.andserver:api:2.0.5'
    annotationProcessor 'com.yanzhenjie.andserver:processor:2.0.5'
}

然后对项目进行简单的封装,也方便后续进行方案的替换

先实现一个Service,对服务进行启动和停止的控制

class CoreService : JobService() {

    private val mServer by lazy {
        AndServer.serverBuilder(this@CoreService)
            .inetAddress(NetUtils.localIPAddress)
            .port(ServerManager.SERVER_PORT)
            .timeout(10, TimeUnit.SECONDS)
            .listener(object : Server.ServerListener {
                override fun onException(e: Exception?) {
                    ServerManager.onServerError(this@CoreService, e?.message ?: "未知错误")
                }

                override fun onStarted() {
                    val hostAddress = NetUtils.localIPAddress?.hostAddress ?: "127.0.0.1"
                    ServerManager.onServerStart(this@CoreService, hostAddress)
                }

                override fun onStopped() {
                    ServerManager.onServerStop(this@CoreService)
                }
            }).build()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startServer()
        return START_STICKY
    }

    override fun onDestroy() {
        stopServer()
        super.onDestroy()
    }

    private fun startServer() {
        if (mServer.isRunning) {
            val hostAddress = NetUtils.localIPAddress?.hostAddress ?: "127.0.0.1"
            ServerManager.onServerStart(this, hostAddress)
        } else mServer.startup()
    }

    private fun stopServer() {
        mServer.shutdown()
    }

    override fun onStopJob(params: JobParameters?): Boolean {
        return false
    }

    override fun onStartJob(params: JobParameters?): Boolean {
        startServer()
        return true
    }
}

Service静态注册


NetUtils是一个工具类,主要是获取本地的IP地址

object NetUtils {

    /*** Get local Ip address.*/
    val localIPAddress: InetAddress?
        get() {
            var enumeration: Enumeration? = null
            try {
                enumeration = NetworkInterface.getNetworkInterfaces()
            } catch (e: SocketException) {
                e.printStackTrace()
            }

            if (enumeration != null) {
                while (enumeration.hasMoreElements()) {
                    val nif = enumeration.nextElement()
                    val inetAddresses = nif.inetAddresses
                    if (inetAddresses != null) {
                        while (inetAddresses.hasMoreElements()) {
                            val inetAddress = inetAddresses.nextElement()
                            if (inetAddress.toString().contains("127.0.0.1")) {
                                return inetAddress
                            }
                        }
                    }
                }
            }
            return null
        }
}

这里说一下我为什么要绑定127.0.0.1的本地地址,因为我们项目中有缓存机制,选择的视频在切换网络过后还要求能够预览,如果绑定动态分配的ip地址,那么在切换网络过后,可能会造成ip地址发生变化,之前h5选择的资源文件会无法预览,如果我们绑定127.0.0.1,无论是切换网络还是完全关闭网络,都可以访问到之前选择的文件资源(只要本地文件没有删除),并且只有在本应用内才能够访问到,安全性提高。

接下来就是ServerManager,AndServer的全局控制类

class ServerManager constructor(
    private val mContext: Context, private val mListener: ServerChangeListener
) : BroadcastReceiver() {
    private val mService: Intent = Intent(mContext, CoreService::class.java)

    fun register() {
        runCatching {
            val filter = IntentFilter(ACTION)
            mContext.registerReceiver(this, filter)
        }.onFailure {
            it.handle()
        }
    }

    fun unRegister() {
        runCatching {
            mContext.unregisterReceiver(this)
        }.onFailure {
            it.handle()
        }
    }

    fun startServer() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val builder = JobInfo.Builder(JOB_ID, ComponentName(mContext, CoreService::class.java))
                .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY).build()
            val jobScheduler = mContext.getSystemService(JobScheduler::class.java) as JobScheduler
            jobScheduler.schedule(builder)
        } else {
            mContext.startService(mService)
        }
    }

    fun stopServer() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val jobScheduler = mContext.getSystemService(JobScheduler::class.java) as JobScheduler
            jobScheduler.cancel(JOB_ID)
        } else {
            mContext.stopService(mService)
        }
    }

    override fun onReceive(context: Context?, intent: Intent?) {
        val action = intent?.action ?: return
        if (ACTION == action) {
            when (intent.getIntExtra(CMD_KEY, 0)) {
                CMD_VALUE_START -> {
                    val ip = intent.getStringExtra(MESSAGE_KEY)
                    mListener.onServerStart(ip)
                }

                CMD_VALUE_ERROR -> {
                    val errMsg = intent.getStringExtra(MESSAGE_KEY)
                    mListener.onServerError(errMsg)
                }

                CMD_VALUE_STOP -> mListener.onServerStop()
            }
        }
    }

    companion object {
        const val JOB_ID = 100000

        const val SERVER_PORT = 8088

        private const val ACTION = "com.andserver.receiver"
        private const val CMD_KEY = "CMD_KEY"
        private const val MESSAGE_KEY = "MESSAGE_KEY"

        private const val CMD_VALUE_START = 1
        private const val CMD_VALUE_ERROR = 2
        private const val CMD_VALUE_STOP = 4

        fun onServerStart(context: Context, hostAddress: String) {
            sendBroadcast(context, CMD_VALUE_START, hostAddress)
        }

        fun onServerError(context: Context, error: String) {
            sendBroadcast(context, CMD_VALUE_ERROR, error)
        }

        fun onServerStop(context: Context) {
            sendBroadcast(context, CMD_VALUE_STOP)
        }

        private fun sendBroadcast(context: Context, cmd: Int) {
            sendBroadcast(context, cmd, null)
        }

        private fun sendBroadcast(context: Context, cmd: Int, message: String?) {
            val broadcast = Intent(ACTION)
            broadcast.putExtra(CMD_KEY, cmd)
            broadcast.putExtra(MESSAGE_KEY, message)
            context.sendBroadcast(broadcast)
        }
    }
}

本地服务状态监听接口类定义

interface ServerChangeListener {

    fun onServerStart(ipAddress: String)

    fun onServerStop()

    fun onServerError(msg: String)
}

最后是文件请求Controler,处理文件请求响应

@RestController
class TransferFileController {
    @GetMapping("/file")
    fun browseFile(@RequestParam(name = "fileId") fileId: String, response: HttpResponse) {
        val mFile = File(fileId)
        val mBody = FileBody(mFile)
        response.setBody(mBody)
    }
}

这里需要注意的一点是,如果你的文件路名含有&特殊字符,那么fileId请求参数就会被自动截断。遇到这种情况,一般有两种处理方式,第一种是使用post请求,把参数放在body中;第二种方式是我们在把文件路径传递给h5之前,我们先进行编码,将特殊字符替换掉,然后我们在收到请求后,去寻找本地文件之前对编码的路径进行还原

最后再对启动入口进行一次封装,对外提供启动服务的入口

object ServerUtil {
    private var serverManager: ServerManager? = null
    var baseUrl = ""  //服务基地址
    var fileUrl = ""  //文件服务地址

    fun initWebServer(activity: Activity?) {
        if (serverManager == null) {
            serverManager = ServerManager(
                activity ?: GlobalContextProvider.getGlobalContext(),
                object : ServerChangeListener {
                    override fun onServerStart(ipAddress: String) {
                        Logger.d("hsc  启动微服务:  $ipAddress")
                        baseUrl = "http://$ipAddress:${ServerManager.SERVER_PORT}"
                        fileUrl = "http://$ipAddress:${ServerManager.SERVER_PORT}/file"
                    }

                    override fun onServerStop() = Logger.d("停止微服务")
                    override fun onServerError(msg: String) = Logger.d("微服务启动报错:$msg")
                })
        }
        serverManager?.register()
        serverManager?.startServer()
    }

    fun unRegWebServer() {
        serverManager?.stopServer()
        serverManager?.unRegister()
        serverManager = null
    }
}

然后在你需要使用服务之前,调用initWebServer()启动服务就可以了,需要注意一点的就是,你可能需要全局监听网络变化,网络变化后,你需要重启一次服务才能够访问到资源

你可能感兴趣的:(h5与原生的大文件交互解决策略)