Skip to content

纯原生接入(Android / iOS) ​

接入方式 ​

先按 接入总览 获取观看地址及按需生成登录 URL,再创建原生 WebView。Android 通过 JavaScript Interface 收消息,iOS 通过 WKScriptMessageHandler 收消息;两端均向 H5 提供 统一交互协议 中的 Promise 接口。

下面分别给出 H5 发起请求、Android / iOS 接收并回传,以及 Native 调用 H5 并读取结果的代码。示例业务事件为 getAppInfo、openNativePage、getVideoInfo 和 appLifecycle。其中 openNativePage 为自定义事件示例,需要 App 实现对应分发。

系统消息接口本身不会为 H5 返回 Promise,因此下载 bridgeRuntime.js 随 App 打包。它只负责两端消息配对和兼容 callback,业务事件仍使用现有协议。Flutter 通过插件即可完成这一步,不需要该脚本。

H5 调 Native示例:H5 侧发起请求 ​

Android、iOS 的 H5 调用代码相同。下面的按钮事件应在容器桥接就绪后触发:

javascript
async function onGetAppInfoClick() {
  var bridge = window.htmlToFlutterEvent;
  if (!bridge || typeof bridge.postMessage !== 'function') {
    throw new Error('App 通道尚未就绪');
  }
  var responseText = await bridge.postMessage(JSON.stringify({
    event: 'getAppInfo',
    data: {}
  }));
  var result = JSON.parse(responseText);
  if (Number(result.code) !== 200) throw new Error(result.message);
  console.log(result.data.appName, result.data.version, result.data.platform);
}

async function onOpenNativePageClick() {
  var bridge = window.htmlToFlutterEvent;
  if (!bridge || typeof bridge.postMessage !== 'function') {
    throw new Error('App 通道尚未就绪');
  }
  var responseText = await bridge.postMessage(JSON.stringify({
    event: 'openNativePage',
    data: { page: 'orderDetail', params: { orderId: 'order_12345' } }
  }));
  var result = JSON.parse(responseText);
  if (Number(result.code) !== 200) throw new Error(result.message);
  return result.data;
}

以下 Android 的 AppNative.postMessage 和 iOS 的 didReceive 分别接收这次请求,将结果交回 AppBridgeAdapter.receive,上方的 await 才会拿到结果。

Android 接入示例 ​

1. H5 调 Native示例:注册通道、接收并回传 ​

将脚本放入 Android 的 assets/bridgeRuntime.js。示例使用 AndroidX WebKit 的文档开始注入能力,不支持时在加载完成后注入作为兼容处理。

kotlin
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.TextView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone

class LiveWebActivity : Activity() {
    private lateinit var web: WebView
    private var generation = 0
    private val origin = "https://live.example.com"

    private fun trusted(url: String?): Boolean {
        val uri = Uri.parse(url ?: return false)
        return uri.scheme == "https" && uri.host == "live.example.com" &&
            (uri.port == -1 || uri.port == 443)
    }

    override fun onCreate(state: Bundle?) {
        super.onCreate(state)
        web = WebView(this)
        setContentView(web)
        web.settings.javaScriptEnabled = true
        web.settings.domStorageEnabled = true
        web.settings.userAgentString +=
            " channel/APPAndroid version/1.0.0 package/com.example.live" +
            " wxappid/example_wechat_appid scheme/examplelive"

        val runtime = assets.open("bridgeRuntime.js").bufferedReader().use { it.readText() }
        val script = runtime + """
            window.AppBridgeAdapter.install(function(id, payload) {
                window.AppNative.postMessage(id, payload);
            });
        """.trimIndent()

        web.addJavascriptInterface(object {
            @JavascriptInterface
            fun postMessage(id: String, payload: String) {
                web.post {
                    if (!trusted(web.url)) return@post
                    val page = generation
                    val result = dispatch(payload).toString()
                    if (page == generation) {
                        val reply = "window.AppBridgeAdapter && window.AppBridgeAdapter.receive(" +
                            JSONObject.quote(id) + "," + JSONObject.quote(result) + ");"
                        web.evaluateJavascript(reply, null)
                    }
                }
            }
        }, "AppNative")

        if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
            WebViewCompat.addDocumentStartJavaScript(web, script, setOf(origin))
        }
        web.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                return !trusted(request.url.toString())
            }
            override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
                generation++
            }
            override fun onPageFinished(view: WebView, url: String?) {
                if (trusted(url)) view.evaluateJavascript(script, null)
            }
        }
        // 替换为服务端刚生成的完整观看 URL,保留登录参数。
        val url = intent.getStringExtra("watchUrl") ?: return finish()
        if (trusted(url)) web.loadUrl(url) else finish()
    }

    private fun dispatch(payload: String): JSONObject {
        val request = try { JSONObject(payload) } catch (_: Exception) { JSONObject() }
        val event = request.optString("event")
        val timestamp = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
            timeZone = TimeZone.getTimeZone("UTC")
        }.format(Date())
        val result = JSONObject().put("event", event).put("timestamp", timestamp)
            .put("code", 400).put("message", "未知事件").put("data", JSONObject.NULL)
        if (event == "getAppInfo") {
            result.put("code", 200).put("message", "").put("data", JSONObject()
                .put("appName", "示例应用").put("packageName", "com.example.live")
                .put("version", "1.0.0").put("buildNumber", "1")
                .put("platform", "Android").put("scheme", "examplelive://"))
        } else if (event == "openNativePage") {
            val data = request.optJSONObject("data")
            val params = data?.optJSONObject("params")
            val orderId = (params?.opt("orderId") as? String)?.trim().orEmpty()
            if (data?.optString("page") != "orderDetail" || orderId.isEmpty()) {
                result.put("message", "页面或订单参数错误")
            } else {
                try {
                    startActivity(Intent(this, OrderDetailActivity::class.java)
                        .putExtra("orderId", orderId))
                    result.put("code", 200).put("message", "")
                        .put("data", JSONObject().put("page", "orderDetail"))
                } catch (_: Exception) {
                    result.put("code", 500).put("message", "无法打开 App 页面")
                }
            }
        }
        return result
    }

    override fun onDestroy() {
        generation++
        web.removeJavascriptInterface("AppNative")
        web.destroy()
        super.onDestroy()
    }
}

// 最小演示页面,实际接入时替换为项目的订单详情 Activity。
class OrderDetailActivity : Activity() {
    override fun onCreate(state: Bundle?) {
        super.onCreate(state)
        val orderId = intent.getStringExtra("orderId").orEmpty()
        setContentView(TextView(this).apply { text = "订单号:$orderId" })
    }
}

为 Activity 配置网络权限,并在项目中引入支持上述 API 的 AndroidX WebKit 依赖。示例中的可信域名应替换为实际观看域名;如需跨域跳转,应逐项配置实际允许的 HTTPS 来源。

openNativePage 的 orderDetail 映射到 OrderDetailActivity,通过 Intent 携带 orderId。在接入项目的 AndroidManifest.xml 中注册两个 Activity,类路径按实际包名填写:

AndroidManifest.xml 配置示例:

xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <uses-permission android:name="android.permission.INTERNET" />
  <application>
    <activity android:name=".LiveWebActivity" android:exported="false" />
    <activity android:name=".OrderDetailActivity" android:exported="false" />
  </application>
</manifest>

JavaScript Interface 的回调不在 UI 线程,操作 WebView 或页面导航要切回主线程。异步分享等业务应在接收请求时保存当前页面代次,完成后确认页面仍有效再发送结果。Interface 可能被子框架调用,示例应仅承载可信页面及其可信框架内容。

文档开始注入不可用时,在 onPageFinished 注入脚本,并让 H5 在通道就绪后发起调用。onPageFinished 与文档开始是不同的执行时机。

2. Native 调 H5示例:执行函数并读取返回值 ​

将以下方法放入同一个 LiveWebActivity,在主线程、H5 加载完成后调用。参数使用 JSON 序列化,evaluateJavascript 的执行回调用于读取 H5 的返回值。

kotlin
private fun callH5(event: String, data: Any?, onResult: (JSONObject?) -> Unit) {
    val timestamp = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
        timeZone = TimeZone.getTimeZone("UTC")
    }.format(Date())
    val message = JSONObject().put("event", event).put("timestamp", timestamp)
        .put("data", data ?: JSONObject.NULL)
    web.evaluateJavascript(
        "window.flutterToHtmlEvent && window.flutterToHtmlEvent($message);"
    ) { raw ->
        val result = try {
            val value = org.json.JSONTokener(raw).nextValue()
            if (value is String) JSONObject(value) else value as? JSONObject
        } catch (_: Exception) { null }
        onResult(result)
    }
}

private fun queryVideoSize() {
    callH5("getVideoInfo", null) { result ->
        if (result?.optInt("code") == 200) {
            val video = result.optJSONObject("data")
            val width = video?.optInt("width")
            val height = video?.optInt("height")
            // 使用 width、height 判断横竖屏布局。
        }
    }
}

private fun notifyAppResumed() {
    callH5("appLifecycle", "resumed") { _ -> }
}

通过 WebChromeClient 处理视频全屏及退出全屏。系统返回键优先检查 web.canGoBack(),否则发送 pageLifecycle / dispose 并在 JavaScript 执行回调后关闭 Activity。onDestroy 用于兜底释放资源,正常关闭通知应提前发送。

iOS 接入示例 ​

1. H5 调 Native示例:创建容器、接收并回传 ​

将脚本加入 App Bundle。配置内联播放、UA 和主框架脚本后再首次加载 URL。

swift
import UIKit
import WebKit

private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
    weak var target: WKScriptMessageHandler?
    init(_ target: WKScriptMessageHandler) { self.target = target }
    func userContentController(_ controller: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        target?.userContentController(controller, didReceive: message)
    }
}

final class LiveWebController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate {
    var watchURL: URL!
    private var web: WKWebView!

    private func trusted(_ url: URL?) -> Bool {
        return url?.scheme == "https" && url?.host == "live.example.com" &&
            (url?.port == nil || url?.port == 443)
    }

    private func literal(_ value: Any) throws -> String {
        let data = try JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed])
        return String(decoding: data, as: UTF8.self)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        guard trusted(watchURL),
              let scriptURL = Bundle.main.url(forResource: "bridgeRuntime", withExtension: "js"),
              let runtime = try? String(contentsOf: scriptURL, encoding: .utf8) else { return }
        let config = WKWebViewConfiguration()
        config.allowsInlineMediaPlayback = true
        config.applicationNameForUserAgent =
            "channel/APPiOS version/1.0.0 package/com.example.live " +
            "wxappid/example_wechat_appid scheme/examplelive"
        config.userContentController.add(WeakMessageHandler(self), name: "AppNative")
        let script = runtime + """
        window.AppBridgeAdapter.install(function(id, payload) {
            window.webkit.messageHandlers.AppNative.postMessage({id: id, payload: payload});
        });
        """
        config.userContentController.addUserScript(WKUserScript(
            source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
        web = WKWebView(frame: view.bounds, configuration: config)
        web.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        web.navigationDelegate = self
        view.addSubview(web)
        web.load(URLRequest(url: watchURL))
    }

    func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        decisionHandler(trusted(action.request.url) ? .allow : .cancel)
    }

    func userContentController(_ controller: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        guard message.frameInfo.isMainFrame, trusted(message.frameInfo.request.url),
              let body = message.body as? [String: Any],
              let id = body["id"] as? String, let payload = body["payload"] as? String,
              let bytes = payload.data(using: .utf8),
              let request = (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any]
        else { return }
        let event = request["event"] as? String ?? ""
        var result: [String: Any] = [
            "event": event, "timestamp": ISO8601DateFormatter().string(from: Date()),
            "code": 400, "message": "未知事件", "data": NSNull()
        ]
        if event == "getAppInfo" {
            result["code"] = 200
            result["message"] = ""
            result["data"] = ["appName": "示例应用", "packageName": "com.example.live",
                              "version": "1.0.0", "buildNumber": "1",
                              "platform": "iOS", "scheme": "examplelive://"]
        } else if event == "openNativePage" {
            let data = request["data"] as? [String: Any]
            let params = data?["params"] as? [String: Any]
            let orderId = (params?["orderId"] as? String ?? "")
                .trimmingCharacters(in: .whitespacesAndNewlines)
            if data?["page"] as? String != "orderDetail" || orderId.isEmpty {
                result["message"] = "页面或订单参数错误"
            } else if navigationController == nil && presentedViewController != nil {
                result["code"] = 500
                result["message"] = "当前无法打开 App 页面"
            } else {
                // 最小演示页面,实际接入时替换为项目的订单详情控制器。
                let detail = UIViewController()
                detail.title = "订单详情"
                detail.view.backgroundColor = .white
                let label = UILabel()
                label.text = "订单号:\(orderId)"
                label.translatesAutoresizingMaskIntoConstraints = false
                detail.view.addSubview(label)
                NSLayoutConstraint.activate([
                    label.centerXAnchor.constraint(equalTo: detail.view.centerXAnchor),
                    label.centerYAnchor.constraint(equalTo: detail.view.centerYAnchor)
                ])
                if let navigation = navigationController {
                    navigation.pushViewController(detail, animated: true)
                } else {
                    present(UINavigationController(rootViewController: detail), animated: true)
                }
                result["code"] = 200
                result["message"] = ""
                result["data"] = ["page": "orderDetail"]
            }
        }
        guard let response = try? literal(result),
              let idJSON = try? literal(id), let responseJSON = try? literal(response) else { return }
        web.evaluateJavaScript(
            "window.AppBridgeAdapter && window.AppBridgeAdapter.receive(\(idJSON),\(responseJSON));",
            completionHandler: nil)
    }

    deinit {
        web?.configuration.userContentController.removeScriptMessageHandler(forName: "AppNative")
    }
}

消息处理器使用弱引用代理,避免控制器与 WKWebView 相互持有。openNativePage 将 orderDetail 映射为订单详情控制器,优先压入导航栈,无导航栈时以模态页面打开;演示页面请替换为项目实现。示例在发起跳转后立即返回,不等待目标页面关闭。接入异步能力时,需要额外校验请求仍属于当前文档,旧页面结果直接丢弃。

2. Native 调 H5示例:执行函数并读取返回值 ​

将下列方法放入同一个 LiveWebController,复用上面的 literal 序列化方法。getVideoInfo 查询 H5 视频尺寸,appLifecycle 通知前后台变化。

swift
func callH5(_ event: String, data: Any = NSNull(),
            completion: @escaping ([String: Any]?) -> Void) {
    let message: [String: Any] = [
        "event": event,
        "timestamp": ISO8601DateFormatter().string(from: Date()),
        "data": data
    ]
    guard let json = try? literal(message) else { completion(nil); return }
    web.evaluateJavaScript(
        "window.flutterToHtmlEvent && window.flutterToHtmlEvent(\(json));"
    ) { value, error in
        guard error == nil else { completion(nil); return }
        if let object = value as? [String: Any] {
            completion(object)
        } else if let text = value as? String, let bytes = text.data(using: .utf8) {
            completion((try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any])
        } else {
            completion(nil)
        }
    }
}

func queryVideoSize() {
    callH5("getVideoInfo") { result in
        guard let result = result, String(describing: result["code"] ?? "") == "200",
              let video = result["data"] as? [String: Any] else { return }
        print(video["width"] ?? 0, video["height"] ?? 0)
    }
}

func notifyAppResumed() {
    callH5("appLifecycle", data: "resumed") { _ in }
}

3. 页面关闭 ​

App 回到前台时,将包含 event、timestamp、data 的字典序列化为 JSON,通过 evaluateJavaScript 调用 window.flutterToHtmlEvent(对象)。关闭时先发送 pageLifecycle / dispose,在执行完成回调后再 pop 或 dismiss。

页面被覆盖、恢复显示时映射相应 pageLifecycle;可返回时调用 goBack(),不可返回时关闭容器。不要在每次 viewWillDisappear 时直接销毁容器,因为打开分享面板等操作也可能暂时覆盖当前页。

Native 调 H5示例:H5 侧接收并返回 ​

Android / iOS 调用的是同一个 H5 函数。现有直播间已经实现该函数,App 只需调用;以下是自建测试页面的对应示例,用来验证上面的原生代码,不要覆盖线上直播间的实现。

javascript
window.flutterToHtmlEvent = function (message) {
  var result = {
    event: message.event, timestamp: Date.now(),
    code: 200, message: '', data: null
  };
  if (message.event === 'getVideoInfo') {
    var video = document.querySelector('video');
    if (video) {
      result.data = { width: video.videoWidth, height: video.videoHeight };
    } else {
      result.code = 401;
      result.message = '播放器尚未就绪';
    }
  } else if (message.event === 'appLifecycle') {
    console.log('App 生命周期', message.data);
  } else if (message.event === 'pageLifecycle') {
    console.log('页面生命周期', message.data);
  } else {
    result.code = 400;
    result.message = '未知事件';
  }
  return result;
};

H5 返回的是对象,Android 和 iOS 分别在 JavaScript 执行完成回调中取得结果;这与 H5 调 Native 时等待 postMessage 的 Promise 是两个不同方向。

接入检查 ​

  • Android 与 iOS 均验证 getAppInfo 的 Promise 和 callback,以及含引号、换行的字符串回传。
  • 调用 openNativePage,确认收到订单参数并发起页面跳转;验证未知页面、缺失订单号和跳转失败时的响应。
  • 页面刷新、重定向、返回后重新验证 Bridge,检查不可信导航是否被拦截。
  • 验证内联播放、全屏、方向切换、系统返回及 App 前后台通知。
  • 验证关闭时释放 WebView,分享等异步任务不会向已销毁页面发送结果。