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静态注册
<service
android:name=".server.CoreService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:enabled="true"
android:exported="true" />
NetUtils是一个工具类,主要是获取本地的IP地址
object NetUtils {
/*** Get local Ip address.*/
val localIPAddress: InetAddress?
get() {
var enumeration: Enumeration<NetworkInterface>? = 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()启动服务就可以了,需要注意一点的就是,你可能需要全局监听网络变化,网络变化后,你需要重启一次服务才能够访问到资源