1、前言
最近一个应急平台的项目移动端开发,原计划用UNI-APP实现,客户想着要集成语音、视频通话功能,基于经验判断需要买一套IM原生移动端框架去结合H5整合比较合适,没想到最后客户不想采购,而且语音视频通话功能也迟迟未能完全确认,H5部分所开发的业务功能已经实现,但原生端开发模式迟迟未定,紧急时刻,决定启动前几年一直使用的一组android原生APP+H5(WEB)实现移动端开发,随即找了前几年的原生框架代码,发现与新的版本已不兼容,索性重新梳理,整理一套新的代码,也决定对外开放给朋友们使用,暂时延续之前内部框架名称JoApp,目前只整理了android+h5代码,后续还会将IOS版整理出来。
恰逢2024年第一天元旦,祝福各位朋友新年快乐!这个节假日老哥我最大收获就是这个框架中实现了人脸识别、人脸对比的API,满足各类应用系统手机APP中实现人脸识别、位置校验的需要,方便大家哪里即用。
本文涉及代码开发工具如下:
Android Studio Giraffe | 2022.3.1 Patch 3、VSCode
语言及管理:
Java Jdk(OpenJDK17)、Kotlin、Gradle-8.4
2、原生APP与H5交互的核心实现
基于JS方法在在APP与WebView内的H5间进行调用实现,这里主要演示Kotilin的代码,如需要JAVA版,可以使用文心一言等智能工具进行转换。
原生APP端核心原理代码如下(写在 MainActivity内):
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 隐藏状态栏和导航栏 requestWindowFeature(Window.FEATURE_NO_TITLE) // 设置窗口全屏 window.setFlags( WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN ) // 获取 WebView 组件 webview = findViewById<WebView>(R.id.web_view) // 获取并设置 Web 设置 val settings = webview?.settings settings?.javaScriptEnabled = true // 支持 JavaScript // 设置是否启用 DOM 存储 // DOM 存储是一种在 Web 应用程序中存储数据的机制,它使用 JavaScript 对象和属性来存储和检索数据 settings?.domStorageEnabled = true // 设置 WebView 是否启用内置缩放控件 ( 自选 非必要 ) //settings.builtInZoomControls = true // 5.0 以上需要设置允许 http 和 https 混合加载 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { settings?.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW } else { // 5.0 以下不用考虑 http 和 https 混合加载 问题 settings?.mixedContentMode = WebSettings.LOAD_NORMAL } // 设置页面自适应 // Viewport 元标记是指在 HTML 页面中的 <meta> 标签 , 可以设置网页在移动端设备上的显示方式和缩放比例 // 设置是否支持 Viewport 元标记的宽度 settings?.useWideViewPort = true // 设置 WebView 是否使用宽视图端口模式 // 宽视图端口模式下 , WebView 会将页面缩小到适应屏幕的宽度 // 没有经过移动端适配的网页 , 不要启用该设置 settings?.loadWithOverviewMode = true // 设置 WebView 是否可以获取焦点 ( 自选 非必要 ) webview?.isFocusable = true // 设置 WebView 是否启用绘图缓存 位图缓存可加速绘图过程 ( 自选 非必要 ) webview?.isDrawingCacheEnabled = true // 设置 WebView 中的滚动条样式 ( 自选 非必要 ) // SCROLLBARS_INSIDE_OVERLAY - 在内容上覆盖滚动条 ( 默认 ) webview?.scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY // WebViewClient 是一个用于处理 WebView 页面加载事件的类 webview?.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { // 4.0 之后必须添加该设置 // 只能加载 http:// 和 https:// 页面 , 不能加载其它协议链接 if (url.startsWith("http://") || url.startsWith("https://")) { view.loadUrl(url) return true } return false } // SSL 证书校验出现异常 override fun onReceivedSslError( view: WebView, handler: SslErrorHandler, error: SslError ) { when (error.primaryError) { SslError.SSL_INVALID, SslError.SSL_UNTRUSTED -> { handler.proceed() } else -> handler.cancel() } } } // WebChromeClient 是一个用于处理 WebView 界面交互事件的类 webview?.webChromeClient = MyWebChromeClient() // 加载网页 webview?.loadUrl(WebUrl) // js调用安卓方法支持(第二个参数是js代码中调用APP中的交互桥类定义的名,需保持一致) webview?.addJavascriptInterface(JoAppObject(),"joApp") // 原生调用js中的方法(不带参数版) // 这里joAppJs与H5 web端中定义的被原生调用JS类new的变量名一致,方便统一调用 joAppJs("joAppJs.test") // 原生调用js中的方法(带参数版) joAppJs("joAppJs.testData","一只可爱的对号") } // 原生调用JS方法,方法名 fun joAppJs(funName: String){ JoDebug.show(this@MainActivity, " - " + funName, Toast.LENGTH_LONG) if (Build.VERSION.SDK_INT< 18) { webview?.loadUrl("javascript:$funName()") } else { // 安卓调用js方法 4.4以上 webview?.evaluateJavascript( "javascript:$funName()", object : ValueCallback<String> { override fun onReceiveValue(res: String?) { //此处为 js 返回的结果 //System.out.print(res) //return res } }) } } // 原生调用JS方法,参数1:JS方法名、参数2:传给JS方法的参数(支持json字符串) fun joAppJs(funName: String, data: String){ // 旧版android支持 if (Build.VERSION.SDK_INT< 18) { if(data==null) { webview?.loadUrl("javascript:$funName()") }else{ webview?.loadUrl("javascript:$funName('$data')") } } else { // 安卓调用js方法 4.4以上 if(data==null) { webview?.evaluateJavascript( "javascript:$funName()", object : ValueCallback<String> { override fun onReceiveValue(res: String?) { //此处为 js 返回的结果 //System.out.print(res) //return res } }) }else{ webview?.evaluateJavascript("javascript:$funName('$data')", object : ValueCallback<String> { override fun onReceiveValue(res: String?) { //此处为 js 返回的结果 //System.out.print(res) //return res } }) } } } /* * JoApp 原生提供给H5可被JS调用的桥类库,真实的原生实现方法类库 需要将与原生交互的各种API类写在这里,实现H5的方便调用 * */ inner class JoAppObject { //测试jsAndroid调用 @JavascriptInterface fun jsAndroid(msg: String) { //点击html的Button调用Android的Toast代码 //我这里让Toast居中显示了 JoDebug.show(this@MainActivity, msg, Toast.LENGTH_LONG) } }
嵌入的H5 WEB中配套代码如下:
...<button type="button" onclick="clickAndroid()">无回传调用安卓方法</button>... <script type="text/javascript"> /* JoAppJs 安卓调用的JS方法库 */ class JoAppJs { //测试不带参数 test () { alert("Android调用了JS代码") document.getElementById("showres").innerHTML = "Android调用了JS代码" } //测试不带参数 testData (data) { alert("Android调用了JS代码" + data) document.getElementById("showres").innerHTML = data } } //定义被APP原生调用的H5中JS类库变量名,方便统一调用 const joAppJs = new JoAppJs() //测试调用原生APP function clickAndroid(){ //用joapp.调用映射的对象 这里的androids是addJavascriptInterface()的第二个参数 joApp.jsAndroid("我是JS,我调用了Android的方法") } </script>
3、JoAPP已实现的交互API方法库
在JoApp中已经实现了一些原生APP与WebView H5中js的交互方法,以下列出当前关键方法,后续会逐步新增在JoApp Git仓库中,也会在后续文章中逐个解析重点API实现原理。
APP已实现的API包括:
配置信息:joConfigAPP接收WEB中token:joToen向WEB发送APP中token:joTokenToWeb启动原生文件上传:joFile启动原生图片上传(浏览相册+拍照):joImage获取原生APP位置信息(经纬度):joLocationAPP接收位置有效性检测参照信息:joCheckLocationAPP接收人脸有效性检测参照信息:joCheckFace启动APP人脸及位置有效性对比功能:joFaceCompare启动APP设置界面(配置WEB网址):joSetting具体代码如下,请根据需要自行依据注释进行使用:
//权限 var permissions = arrayOf( Manifest.permission.READ_PHONE_STATE, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.MOUNT_UNMOUNT_FILESYSTEMS, Manifest.permission.ACCESS_NETWORK_STATE, Manifest.permission.ACCESS_WIFI_STATE, Manifest.permission.SYSTEM_ALERT_WINDOW, Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.CHANGE_WIFI_STATE, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_LOCATION_EXTRA_COMMANDS, Manifest.permission.CHANGE_NETWORK_STATE, Manifest.permission.GET_TASKS, Manifest.permission.VIBRATE, Manifest.permission.CAMERA, ) private fun initPermission() { MPermissionUtils.requestPermissionsResult( this@MainActivity, 1, permissions, object : MPermissionUtils.OnPermissionListener { override fun onPermissionGranted() {} override fun onPermissionDenied() { MPermissionUtils.showTipsDialog(this@MainActivity) } }) } // 加载完成后自动调取的js fun onLoagJs() { //joAppJs("joAppJs.test") //joAppJs("joAppJs.testData","我的神") //获取H5中包括接口地址在内的设置等信息,用于传递H5中的默认信息给原生app //改由web页面加载后向原生单向推送 //joAppJs("joAppJs.config") //向web传入app缓存中的token //改由web页面加载后向原生推送 //joAppJs("joAppJs.token"); } // 调用JS方法, 方法名、参数(支持json字符串) fun joAppJs(funName: String){ JoDebug.show(this@MainActivity, " - " + funName, Toast.LENGTH_LONG) if (Build.VERSION.SDK_INT< 18) { webview?.loadUrl("javascript:$funName()") } else { // 安卓调用js方法 4.4以上 webview?.evaluateJavascript( "javascript:$funName()", object : ValueCallback<String> { override fun onReceiveValue(res: String?) { //此处为 js 返回的结果 //System.out.print(res) //return res } }) } } fun joAppJs(funName: String, data: String){ if (Build.VERSION.SDK_INT< 18) { if(data==null) { webview?.loadUrl("javascript:$funName()") }else{ webview?.loadUrl("javascript:$funName('$data')") } } else { // 安卓调用js方法 4.4以上 if(data==null) { webview?.evaluateJavascript( "javascript:$funName()", object : ValueCallback<String> { override fun onReceiveValue(res: String?) { //此处为 js 返回的结果 //System.out.print(res) //return res } }) }else{ webview?.evaluateJavascript("javascript:$funName('$data')", object : ValueCallback<String> { override fun onReceiveValue(res: String?) { //此处为 js 返回的结果 //System.out.print(res) //return res } }) } } } //跳转到下一个页面 fun OpenSetting() { val intent = Intent(this, SettingActivity::class.java) startActivity(intent) finish() } //启动人脸对比窗口 fun onFaceStart() { val intent = Intent(); //intent.setClass(this@MainActivity, FaceCheckActivity::class.java) intent.setClass(this@MainActivity, FaceCompareActivity::class.java) startActivity(intent) } // 接收文件选择器回传信息 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { FilePickerManager.REQUEST_CODE -> { if (resultCode == Activity.RESULT_OK) { // 收到选择文件列表 val list = FilePickerManager.obtainData() // 执行上传等工作 Toast.makeText(this@MainActivity, "你选择了文件数" + list.size, Toast.LENGTH_SHORT).show() } else { Toast.makeText(this@MainActivity, "你未执行任何选择", Toast.LENGTH_SHORT).show() } } } } /* * JoApp JS调用原生桥类库 * */ inner class JoAppObject { //测试jsAndroid调用 @JavascriptInterface fun jsAndroid(msg: String) { //点击html的Button调用Android的Toast代码 //我这里让Toast居中显示了 JoDebug.show(this@MainActivity, msg, Toast.LENGTH_LONG) } //接收js传回的web端配置,统一app端原生与嵌套H5的接口 @JavascriptInterface fun joConfig(config: String) { //解析json字符串 val jsonObject = JSONObject(config) val joApiUrl: String = jsonObject.getString("ApiUrl") val joAppTitle: String = jsonObject.getString("AppTitle") val joUpBucketName: String = jsonObject.getString("UpBucketName") val joUpFileName: String = jsonObject.getString("UpFileName") val joAuthorization: String = jsonObject.getString("Authorization") IsDebug = jsonObject.getString("IsDebug") PreferencesUtils.putString(this@MainActivity, "IsDebug", IsDebug) ApiUrl = joApiUrl PreferencesUtils.putString(this@MainActivity, "ApiUrl", ApiUrl) FileUpApi= ApiUrl + "common/upload"; //文件上传接口 PreferencesUtils.putString(this@MainActivity, "FileUpApi", FileUpApi) ImageUpApi= ApiUrl + "common/upload"; //图片上传接口 PreferencesUtils.putString(this@MainActivity, "ImageUpApi", ImageUpApi) VideoUpApi= ApiUrl + "common/upload"; //视频上传接口 PreferencesUtils.putString(this@MainActivity, "VideoUpApi", VideoUpApi) Authorization = joAuthorization PreferencesUtils.putString(this@MainActivity, "Authorization", Authorization) Applicationcode = jsonObject.getString("Applicationcode") PreferencesUtils.putString(this@MainActivity, "Applicationcode", Applicationcode) ApplicationcodeValue = jsonObject.getString("ApplicationcodeValue") PreferencesUtils.putString(this@MainActivity, "ApplicationcodeValue", ApplicationcodeValue) AppTitle = joAppTitle PreferencesUtils.putString(this@MainActivity, "AppTitle", AppTitle) UpBucketName = joUpBucketName; //上传默认盒 PreferencesUtils.putString(this@MainActivity, "UpBucketName", UpBucketName) UpFileName = joUpFileName; //上传模拟文件字段名 PreferencesUtils.putString(this@MainActivity, "UpFileName", UpFileName) //我这里让Toast居中显示了 JoDebug.show(this@MainActivity, ApiUrl + " - " + joAppTitle, Toast.LENGTH_LONG) } //接收js传回的web端token,统一app端原生与嵌套H5的token验证 @JavascriptInterface fun joToken(token: String) { JoDebug.show(this@MainActivity, " Token1 - " + Token, Toast.LENGTH_LONG) // 存储token PreferencesUtils.putString(this@MainActivity, "token", token) //解析json字符串 Token = PreferencesUtils.getString(this@MainActivity, "token"); JoDebug.show(this@MainActivity, " Token - " + Token, Toast.LENGTH_LONG) } //将APP中token传入web,实现web根据app存储的token自动登录 @JavascriptInterface fun joTokenToWeb() { Token = PreferencesUtils.getString(this@MainActivity, "token"); joAppJs("joAppJs.setToken", Token); } //文件选择、上传 @JavascriptInterface fun joFile(returnFunName: String, data: String) { //点击html的Button调用Android的Toast代码 //我这里让Toast居中显示了 JoDebug.show(this@MainActivity, returnFunName + " - " + data, Toast.LENGTH_LONG) //调用上传方法 //JoFile.joFile(this@MainActivity, webview, returnFunName, data) } //图片选择、上传 @JavascriptInterface fun joImage(returnFunName: String, data: String) { //点击html的Button调用Android的Toast代码 //我这里让Toast居中显示了 JoDebug.show(this@MainActivity, returnFunName + " - " + data, Toast.LENGTH_LONG) //调用上传方法 JoImage.joImage(this@MainActivity, webview, returnFunName, data) } //位置信息获取经纬度 @JavascriptInterface fun joLocation(returnFunName: String, data: String) { JoDebug.show(this@MainActivity, returnFunName + " - " + data, Toast.LENGTH_LONG) //调用位置获取方法 JoLocation.LatLng(this@MainActivity, webview, returnFunName, data) } //写入位置范围检测信息,参照点位经度、维度、距离 @JavascriptInterface fun joCheckLocation(data: String) { // 存储token PreferencesUtils.putString(this@MainActivity, "CheckLocation", data) } //写入人脸比对校验信息,参照人脸URL,姓名,达标相似度 @JavascriptInterface fun joCheckFace(data: String) { // 存储token PreferencesUtils.putString(this@MainActivity, "CheckFace", data) } //人脸信息对比 @JavascriptInterface fun joFaceCompare(returnFunName: String, data: String) { JoDebug.show(this@MainActivity, returnFunName + " - " + data, Toast.LENGTH_LONG) //人脸对比获取方法 onFaceStart(); } //打开本地人脸库 @JavascriptInterface fun joFaceData() { //JoDebug.show(this@MainActivity, returnFunName + " - " + data, Toast.LENGTH_LONG) //人脸对比获取方法 val intent = Intent(); intent.setClass(this@MainActivity, SearchNaviActivity::class.java) startActivity(intent) } //打开APP设置界面 @JavascriptInterface fun joSetting() { OpenSetting() } @JavascriptInterface fun jsAndroidRes(msg: String, resJsFun: String) { //this@MainActivity.webview?.loadUrl("javascript:$resJsFun()") //回传数据给js //, "数据回来啦!" JoDebug.show(this@MainActivity, " - " + resJsFun, Toast.LENGTH_LONG) //点击html的Button调用Android的Toast代码 //我这里让Toast居中显示了 JoDebug.show(this@MainActivity, msg + " - " + resJsFun, Toast.LENGTH_LONG) } } // 重定义web弹窗 inner class MyWebChromeClient:WebChromeClient(){ // 显示 网页加载 进度条 override fun onProgressChanged(view: WebView?, newProgress: Int) { Log.d("JoApp","${newProgress}") super.onProgressChanged(view, newProgress) if (newProgress == 100) { //加载100% Log.d(TAG, "onProgressChanged: " + "webView---100%"); //执行加载完成调用js,如:传入token等 onLoagJs()// if (!isWebViewloadError && View.VISIBLE == btnRetry.getVisibility()){// btnRetry.setVisibility(View.GONE);//重新加载按钮// } } } // 处理 WebView 对地理位置权限的请求 override fun onGeolocationPermissionsShowPrompt( origin: String, callback: GeolocationPermissions.Callback) { super.onGeolocationPermissionsShowPrompt(origin, callback) callback.invoke(origin, true, false) } override fun onJsAlert( view: WebView?, url: String?, message: String?, result: JsResult? ): Boolean { Log.d("JoApp","$message + $result") return super.onJsAlert(view, url, message, result) } override fun onJsPrompt( view: WebView?, url: String?, message: String?, defaultValue: String?, result: JsPromptResult? ): Boolean { Log.d("JoApp","$message + $result") return super.onJsPrompt(view, url, message, defaultValue, result) } override fun onJsConfirm( view: WebView?, url: String?, message: String?, result: JsResult? ): Boolean { Log.d("JoApp","$message + $result") return super.onJsConfirm(view, url, message, result) } override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { Log.d("JoApp","${consoleMessage?.message()}") return super.onConsoleMessage(consoleMessage) } lateinit var webkitPermissionRequest: PermissionRequest override fun onPermissionRequest(request: PermissionRequest) { webkitPermissionRequest = request val requestedResources = request.resources for (r in requestedResources) { if (r == PermissionRequest.RESOURCE_VIDEO_CAPTURE) { request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) break } } } } /* * 监听窗体间信息传递 * */ inner class MyBroadcastReceive : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.e(TAG,"开始接收....."); val result = intent.getStringExtra("result") val data = intent.getStringExtra("data") if (result != null) { Log.e(TAG,"result:" + result); val jsonData = "{\"code\":\"200\",\"data\":\"$data\"}" //人脸检测结果返回 if (result == "compareFace") { JoPushWeb( jsonData, "joAppJs.compareFace", webview ) } //打开设置窗口 if (result == "openSetting") { OpenSetting() } //保存设置 if (result == "saveSetting") { webViewReload() } //打开进度条 if (result == "progressBar" || result === "progressBar") { val progressBar: ProgressBar = findViewById<ProgressBar>(R.id.progressBar) val pre = data!!.toInt() if (pre >= 100) { //关闭 progressBar.visibility = View.GONE } else { progressBar.visibility = View.VISIBLE progressBar.progress = data.toInt() } }// Log.e(MainActivity.TAG, result) } } }
4、结尾
一定要赶在新年第一天内完成本篇发布,更加详细代码本文暂不作详细讲解。后续将持续发文讲解,并将代码放到这里。本人安卓水平优先,文章适用于众多新手,老手可直接绕过!!!
所有代码免费分享给大家随便使用,无需考虑版权和收费问题,完整代码放在下面的连接中了,请拿走。
joapp: 一个用于原生APP与内嵌WEB间进行交互的代码集合,方便实现H5中对原生APP各种能力的调用,简单易用。 (gitee.com)
附代码结构截图: