搭建一个极简混合开发架构

搭建一个极简混合开发架构

自移动互联网普及之后,h5 开发与原生 APP 开发便迎来了高速的发展,而这两者之间也各有优缺点。而这两者之前也有融合,形成了一种新的开发模式:混合开发。

原生、H5 与混合开发 的优缺点

原生 APP

优点:

  • 可访问众多手机底层所有提供的功能

  • 运行速度快、性能高,用户体验好

  • 安全性较高

缺点:

  • 开发时间长,快则 3 个月左右完成,慢则五个月左右,直接导致成本较高

  • 可移植性比较差,不同平台都要各自开发,同样的逻辑、界面要写两套

  • 发布要受到平台限制

  • 获得新版本时需重新下载应用更新

H5

优点:

  • 支持设备范围广,可以跨平台,编写的代码可以同时在多端执行

  • 开发成本低、周期短

  • 适合展示有段落文章等格式比较丰富的页面

  • 用户可以直接使用最新版本(不需用户手动更新)

缺点:

  • 技术限制,无法直接访问设备硬件和离线存储,体验和性能局限

  • 对联网要求高,离线不能做任何操作

  • APP 反应速度慢,页面切换流畅性较差

  • 用户体验感较原生 APP 有差距

混合开发

优点:

  • 开发效率高,节约时间。

  • 代码跨平台

  • 更新和部署比较方便,升级小版本只需要在服务器端升级即可,不再需要上传到应用商店进行审核;

  • 代码维护方便、版本更新快,节省产品成本

  • 比 web 版实现功能多;

  • 可离线运行。

缺点:

  • 功能、界面有限

  • 加载缓慢、网络要求高

  • 安全性比较低

  • 需要原生和 H5 都懂

混合开发的形式

而由于以上的优缺点,原生 APP 和 H5 交叉,根据主导程度,划分一下几类

1、以原生做主导,H5 为辅,这种市面上其实还是挺多的,各家的 APP 在不同程度上都有集成

2、以 H5 做主导,原生为辅,这种比较有名的有:uni-app、cordova

3、以 H5 的形式,开发原生,这种主要有:React-Native、Flutter

JsBrigde

什么是 JsBridge?

在一些原生与 H5 混合开发的应用中,由于 H5 的功能有限或是不够完美,通常会在原生应用中提供一些独有的方式,然后暴露到 WebView 中,供 H5 页面使用,而这些 api 一般就被称为 JsBridge。

JsBridge 主要是使用原生安卓 Webview 类的 addJavaScriptInterface 方法提供 API,挂在到 h5 的全局作用域 window 上。

首先,我们创建了一个简单的安卓项目,并给首页添加一个 WebView 组件,设置其 id 为 view_webview

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="?attr/fullscreenBackgroundColor"
    android:theme="@style/ThemeOverlay.JsBridge.FullscreenContainer"
    tools:context=".FullscreenActivity">

    <!-- This FrameLayout insets its children based on system windows using
         android:fitsSystemWindows. -->
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true">

        <LinearLayout
            android:id="@+id/fullscreen_content_controls"
            style="@style/Widget.Theme.JsBridge.ButtonBar.Fullscreen"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|center_horizontal"
            android:orientation="horizontal"
            tools:ignore="UselessParent"/>

        <WebView
            android:id="@+id/view_webview"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </FrameLayout>

</FrameLayout>

然后定义一个 JS 方法类,并添加需要暴露的 JS 方法

public class JavaScriptMethod {
    private Context mContext;

    private WebView mWebView;

    // 需要挂在在 webview 的接口,在 h5 中表现为某个全局对象
    public static final String JAVASCRIPTINTERFACE = "JsBridge";

    // andorid 4.2(包括android4.2)以上,如果不写该注解,js无法调用android方法
    @JavascriptInterface
    public void showToast(String json){
        Toast.makeText(mContext, json, Toast.LENGTH_SHORT).show();
    }
    public JavaScriptMethod(Context context, WebView webView) {
        mContext = context;
        mWebView = webView;
    }
}

在主 Activity 的 onCreate 钩子里,获取页面上的 webview,并添加 js 方法,暴露给 h5 使用

@SuppressLint("JavascriptInterface")
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_fullscreen);

    // 获取页面上的 webview 组件引用
    webView = findViewById(R.id.view_webview);

    // 获取 webview 的设置类
    WebSettings settings = webView.getSettings();

    // 允许在 WebView 中使用 js
    settings.setJavaScriptEnabled(true);

    // 实例化方法类
    JavaScriptMethod method = new JavaScriptMethod(this, webView);

    // 添加 JS 接口
    webView.addJavascriptInterface(method, JavaScriptMethod.JAVASCRIPTINTERFACE);

    // 指定 webview 加载哪个页面
    webView.loadUrl("file:///android_asset/index.html");
}

在 H5 中的调用

window.JsBridge.showToast(JSON.stringify({ code: "toast", data: "abc" }));

注意:由于安卓 9.0 以上在 webview 中默认限制了必须有 https,所有的 http 请求都会被拦截,需要修改配置

加入<uses-permission android:name="android.permission.INTERNET"></uses-permission>android:usesCleartextTraffic="true"

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="cn.failte.jsbridge">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:usesCleartextTraffic="true"
        android:theme="@style/Theme.JsBridge">
        <activity
            android:name=".FullscreenActivity"
            android:configChanges="orientation|keyboardHidden|screenSize"
            android:label="@string/app_name"
            android:theme="@style/Theme.JsBridge.Fullscreen">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
    <uses-permission android:name="android.permission.INTERNET"></uses-permission>
</manifest>

如何读取本地的 html 文件?

读取本地文件和线上文件有点区别,这里需要使用 file 协议头去加载,同时,在项目的 app/src/main 路径下新创建了一个 assets 文件夹,这样就可以通过 file:///android_asset 读取到下面的文件了(这种情况下前端文件是随着 APP 一起打包的)

联想:APP 的热更新怎么做?

步骤

  • 在 APP 初始化时生成前端资源文件夹
  • 下载远程提供的资源包,然后读取该包
  • 打热更新包,并上传到远程,指定可以接收到更新的版本
  • APP 触发更新后,下载远程资源包,并替换本地的资源包,再重新读取资源

Scheme 跳转协议

该方式主要是通过安卓拦截 h5 端请求的 url 地址,并对 url 进行解析,返回结果,从而完成交互。

核心代码

@SuppressLint("JavascriptInterface")
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_fullscreen);

    // 获取页面上的 webview 组件引用
    webView = findViewById(R.id.view_webview);

    // 获取 webview 的设置类
    WebSettings settings = webView.getSettings();

    // 允许在 WebView 中使用 js
    settings.setJavaScriptEnabled(true);

    webView.setWebViewClient(new WebViewClient() {
        // 返回 true,即根据代码逻辑执行相应操作,webview 不加载该url
        // 返回 false,除执行相应代码外,webview 加载该url
        // 返回 super.shouldOverrideUrlLoading(),在父类中,返回的其实还是 false
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, String url) {
            // 通过判断拦截到的url是否含有pre,来辨别是http请求还是调用android方法的请求
            String pre = "failte://android";
            if (url.contains(pre)) {
                // 该url是调用 android 方法的请求
                Map map = getParamsMap(url, pre);
                // 解析 url 中的参数来执行相应方法
                String code = (String) map.get("code");
                String data = (String) map.get("data");
                if(code.equals("toast")) {
                    try {
                        JSONObject json = new JSONObject(data);
                        String toast = (String)json.optString("data");
                        Log.v("toast", toast);
                        Toast.makeText(context, toast, Toast.LENGTH_SHORT).show();
                    } catch (JSONException e) {
                        e.printStackTrace();
                    }
                }
                return true;
            }
            // 放行其他请求,用 webview 加载 url
            return false;
        }
    });

    // 指定 webview 加载哪个页面
    webView.loadUrl("file:///android_asset/index.html");
}

解析 url 的方法,将 url 的 query 参数解析成对象

private Map getParamsMap(String url, String pre) {
    ArrayMap qsMap = new ArrayMap<>();
    if (url.contains(pre)) {
        int index = url.indexOf(pre);
        int end = index + pre.length();
        String queryString = url.substring(end + 1);
        String[] queryStringSplit = queryString.split("&");
        String[] queryStringParam;
        for (String qs : queryStringSplit) {
            if (qs.toLowerCase().startsWith("data=")) {
                //单独处理 data 项,避免 data 内部的 & 被拆分
                int dataIndex = queryString.indexOf("data=");
                String dataValue = queryString.substring(dataIndex + 5);
                qsMap.put("data", dataValue);
            } else {
                queryStringParam = qs.split("=");
                String value = "";
                if (queryStringParam.length > 1) {
                    //避免后台有时候不传值,如 key= 这种
                    value = queryStringParam[1];
                }
                qsMap.put(queryStringParam[0].toLowerCase(), value);
            }
        }
    }
    return qsMap;
}

h5 层面的调用

window.open(
  "failte://android?code=toast&data=" + JSON.stringify({ data: "toast" })
);

挟持 WebView 的原生 js 方法

Webview 的 WebChromeClient 对象上存在 onJsAlert、onJsConfirm、onJsPrompt 方法,主要对应了浏览器端的 window.alert、window.confirm、window.prompt 方法,而由于 window.prompt 方法可以返回数据,因此可以利用该方法来进行通信。

@SuppressLint("JavascriptInterface")
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_fullscreen);

    // 获取页面上的 webview 组件引用
    webView = findViewById(R.id.view_webview);

    // 获取 webview 的设置类
    WebSettings settings = webView.getSettings();

    // 允许在 WebView 中使用 js
    settings.setJavaScriptEnabled(true);

    webView.setWebChromeClient(new WebChromeClient() {
        @Override
        public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
            Log.v("url", url);
            Log.v("message", message);
            Log.v("defaultValue", defaultValue);
            String pre = "cordova://android";
            if (message.contains(pre)) {
                Map map = getParamsMap(message, pre);
                String code = (String) map.get("code");
                String data = (String) map.get("data");
                if(code.equals("plugin")) {
                    try {
                        JSONObject json = new JSONObject(data);
                        String toast = (String)json.optString("data");
                        Log.v("plugin", toast);
                        result.confirm("\"{\"code\": 0}\", \"data\": {}");
                    } catch (JSONException e) {
                        e.printStackTrace();
                    }
                } else {
                    result.cancel();
                }
            }
            return true;
        }
    });

    // 指定 webview 加载哪个页面
    webView.loadUrl("file:///android_asset/index.html");
}
const res = window.prompt(
  "cordova://android?code=plugin&data=" + JSON.stringify({ data: "value" })
);
console.log(res);

总结

至此,一个搭建一个极简混合开发架构就搭好了,接下来可以按照需求去扩展功能了

相关源码

https://github.com/hn-failte/JsBridge

关于

欢迎关注作者个人博客:https://failte.cn/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,204评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,091评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,548评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,657评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,689评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,554评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,302评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,216评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,661评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,851评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,977评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,697评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,306评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,898评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,019评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,138评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,927评论 2 355

推荐阅读更多精彩内容