文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

flutter开发实战-实现webview与Javascript通信JSBridge

2023-08-18 17:30

关注

flutter开发实战-实现webview与H5中Javascript通信JSBridge

在开发中,使用到webview,flutter实现webview是使用原生的插件实现,常用的有webview_flutter与flutter_inappwebview
这里使用的是webview_flutter,在iOS上,WebView小部件由WKWebView支持。在Android上,WebView小部件由WebView支持。

在这里插入图片描述

这里使用的是webview_flutter的3.0.4版本,不同版本代码变化还是挺大的。

一、引webview_flutter

在工程中pubspec.yaml引入webview_flutter

  # 浏览器  webview_flutter: ^3.0.4  webview_cookie_manager: ^2.0.6

二、使用webview

2.1、webview

webview的属性

const WebView({    Key? key,    this.onWebViewCreated,    this.initialUrl,    this.initialCookies = const <WebViewCookie>[],    this.javascriptMode = JavascriptMode.disabled,    this.javascriptChannels,    this.navigationDelegate,    this.gestureRecognizers,    this.onPageStarted,    this.onPageFinished,    this.onProgress,    this.onWebResourceError,    this.debuggingEnabled = false,    this.gestureNavigationEnabled = false,    this.userAgent,    this.zoomEnabled = true,    this.initialMediaPlaybackPolicy =        AutoMediaPlaybackPolicy.require_user_action_for_all_media_types,    this.allowsInlineMediaPlayback = false,    this.backgroundColor,  })

flutter webview和JS交互,需要JavaScript开启。
flutter webview中的javascriptMode参数启用或禁用 JavaScript。默认情况下WebView的 JavaScript是禁用的,所以要想启用的话,可以使用JavascriptMode.unrestricted

WebView(  initialUrl: 'https://www.laileshuo.com',  javascriptMode: JavascriptMode.unrestricted,)

flutter webview提供WebViewController来获取webview信息以及控制webview的刷新、loadUrl、前进、后退等功能。

WebView(  initialUrl: 'https://www.laileshuo.com',  onWebViewCreated: (WebViewController webViewController) {    _controller = webViewController;  },);

2.2、JavascriptChannel

JavascriptChannel用于接收在web视图中运行的JavaScript代码发出的消息,提供了name与onMessageReceived。

JavascriptChannel({    required this.name,    required this.onMessageReceived,  })

我们需要在Webview的javascriptChannels属性设置javascriptChannel!

javascriptChannels: <JavascriptChannel>{        _jsChannelManager.javascriptChannel!,      },

2.3、Cookie

在使用webview的cookie时候,使用initialCookies设置cookie列表
这里我们定义了JSCookieConfig来设置需要设置的cookie

// 处理注入到webview的cookie,设置cookie通过webview_cookie_manager设置所需要的cookie列表// Cookie:不同应用对应不同的key,value为tokenclass JSCookieConfig {  JSCookieConfig() {    eventListener();  }  // cookie  final WebviewCookieManager cookieManager = WebviewCookieManager();  List<WebViewCookie> initialCookies() {    LoggerManager().debug("initialCookies ApiAuth().token:${ApiAuth.getToken()}");    List<WebViewCookie> cookies = [      WebViewCookie(          name: "app_authorization",          value: ApiAuth.getToken(),          domain: ".ifour.cn"),      WebViewCookie(          name: "token", value: ApiAuth.getToken(), domain: ".ifour.cn"),    ];    return cookies;  }  Future<void> setCookies() async {    // final mainCookie = Cookie('app_authorization', 'ApiAuth().token')..domain = 'ifour.cn';    // final h5_tokenCookie = Cookie('token', 'ApiAuth().token')..domain = 'ifour.cn';    //    // await cookieManager.setCookies([    //   mainCookie,    //   h5_tokenCookie    // ]);    await cookieManager.setCookies([      Cookie("app_authorization", ApiAuth.getToken())        ..domain = '.ifour.cn'        ..httpOnly = false,      Cookie("token", ApiAuth.getToken())        ..domain = '.ifour.cn'        ..httpOnly = false,    ]);  }  Future<void> clear() async {    await cookieManager.clearCookies();  }  void eventListener() {    AppEventBus().on(kUserLoginChanged, this, (arg) {      setCookies();    });  }// 注入cookie// String cookieJS =//     "document.cookie ='app_authorization=${ApiAuth().token};domain=.ifour.cn;path=/'";//// _jsChannelManager.injectJavascript(cookieJS);}

2.4、注入JS

JSBridge实现webview上原生与h5的通信,js可以调用native,native也可以调用js,实现通信。
其主要是通过拦截 URL 请求来达到 native 端和 webview 端相互通信的效果,常用的是WebviewJavascriptBridge
这里我们使用代码将WebviewJavascriptBridge的JS代码注入到flutter webview中。

flutter使用的WebviewJavascriptBridge的代码

const String kWebviewJavascriptBridge = '''function preprocessorJS() {    if (window.AppJSBridge) {return;}if (!window.onerror) {window.onerror = function(msg, url, line) {console.log("AppJSBridge: ERROR:" + msg + "@" + url + ":" + line);}}// var messagingIframe;var sendMessageQueue = [];var messageHandlers = {};var CUSTOM_PROTOCOL_SCHEME = 'https';var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';var responseCallbacks = {};var uniqueId = 1;var dispatchMessagesWithTimeoutSafety = true;function registerHandler(handlerName, handler) {messageHandlers[handlerName] = handler;}function callHandler(handlerName, data, responseCallback) {if (arguments.length == 2 && typeof data == 'function') {responseCallback = data;data = null;}_doSend({ handlerName:handlerName, data:data }, responseCallback);}        function call(handlerName, data, responseCallback) {        if (arguments.length == 2 && typeof data == 'function') {            responseCallback = data;            data = null;        }        _doSend({ handlerName:handlerName, data:data }, responseCallback);    }    function disableJavscriptAlertBoxSafetyTimeout() {dispatchMessagesWithTimeoutSafety = false;}function _doSend(message, responseCallback) {if (responseCallback) {var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();responseCallbacks[callbackId] = responseCallback;message['callbackId'] = callbackId;}sendMessageQueue.push(message);// messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;// 通过JavaScriptChannel注入的全局对象window.JSAppSDK.postMessage(JSON.stringify(message))}function _fetchQueue() {var messageQueueString = JSON.stringify(sendMessageQueue);sendMessageQueue = [];return messageQueueString;}function _dispatchMessageFromObjC(messageJSON) {if (dispatchMessagesWithTimeoutSafety) {setTimeout(_doDispatchMessageFromObjC);} else { _doDispatchMessageFromObjC();}// 打印log_consoleLog("AppJSBridge: messageJSON:" + messageJSON);function _doDispatchMessageFromObjC() {var message = JSON.parse(messageJSON);var messageHandler;var responseCallback;if (message.responseId) {responseCallback = responseCallbacks[message.responseId];if (!responseCallback) {return;}responseCallback(message.responseData);delete responseCallbacks[message.responseId];} else {if (message.callbackId) {var callbackResponseId = message.callbackId;responseCallback = function(responseData) {_doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });};}var handler = messageHandlers[message.handlerName];if (!handler) {_consoleLog("AppJSBridge: WARNING: no handler for message from ObjC:", message);} else {handler(message.data, responseCallback);}}}}function _handleMessageFromObjC(messageJSON) {        _dispatchMessageFromObjC(messageJSON);}// messagingIframe = document.createElement('iframe');// messagingIframe.style.display = 'none';// messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;// document.documentElement.appendChild(messagingIframe);registerHandler("_disableJavascriptAlertBoxSafetyTimeout", disableJavscriptAlertBoxSafetyTimeout);// setTimeout(_callWVJBCallbacks, 0);// function _callWVJBCallbacks() {// var callbacks = window.WVJBCallbacks;// delete window.WVJBCallbacks;// for (var i=0; i;

setupWebViewJavascriptBridge与setupWebViewJavascriptBridge判断window.AppJSBridge是否存在,通过监听AppJSBridgeReady来实现window.AppJSBridge初始化,之后js中就可以使用window.AppJSBridge中的registerHandler、callHandler等方法了。

const String kWebviewJsBridgeReady = '''    window.onerror = function(err) {        log('window.onerror: ' + err)    }    function setupWebViewJavascriptBridge(callback) {        if (window.AppJSBridge) {            return callback(AppJSBridge);        } else {            document.addEventListener('AppJSBridgeReady', function() {                callback(AppJSBridge);            },false);        }        // if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }        // window.WVJBCallbacks = [callback];        // var WVJBIframe = document.createElement('iframe');        // WVJBIframe.style.display = 'none';        // WVJBIframe.src = 'https://__bridge_loaded__';        // document.documentElement.appendChild(WVJBIframe);        // setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)    }    setupWebViewJavascriptBridge(function(bridge) {        bridge.registerHandler('testJavascriptHandler', function(data, responseCallback) {            var responseData = { 'Javascript Says':'Right back atcha!' }            responseCallback(responseData)        });        bridge.registerHandler('JSHandler', function(data, responseCallback) {            var responseData = { 'Javascript Says':'Right back atcha!' }            responseCallback(responseData)        });    }''';

在webview的onWebViewCreated将kWebviewJsBridgeReady代码注入,进行监听window.AppJSBridge是否可用。
注入的代码webController的runJavascript方法

_jsChannelManager中的代码

  // 注入js  void injectJavascriptReady() async {    await webController?.runJavascript('javascript:$kWebviewJsBridgeReady');  }

webview的onWebViewCreated,webview创建后

  onWebViewCreated: (controller) {    LoggerManager().debug("onWebViewCreated");    // 注入jsReady    _jsChannelManager.injectJavascriptReady();  },

在webview的onPageFinished将kWebviewJavascriptBridge代码注入

onPageFinished: (String url) {    // 网页加载完成    LoggerManager().debug('onPageFinished url: $url');    // 注入    _jsChannelManager.injectBridgeJavascript();  },

2.5、实现JSChannelManager管理处理H5与flutter webview通信

JSChannelManager中使用JavascriptChannel来接收h5端的JS消息。
当收到H5消息的时候,flutter根据callbackId回调给H5,
实现的具体代码如下

const String kJSChannelName = "JSAppSDK";const String kOldProtocolScheme = "wvjbscheme";const String kNewProtocolScheme = "https";const String kQueueHasMessage = "__wvjb_queue_message__";const String kBridgeLoaded = "__bridge_loaded__";class JSChannelManager {  WebViewController? webController;  BuildContext? context;  JavascriptChannel? javascriptChannel;  // 存储的消息messageHandler  Map<String, dynamic> messageHandlers = {};  // 存储的回调callback, responseCallback  Map<String, dynamic> responseCallbacks = {};  // 开启的消息队列,发送的消息均会存储到该队列中  List<JSMessage>? startupMessageQueue = [];  // 消息的标识  int _uniqueId = 0;  JSChannelManager() {    javascriptChannel = JavascriptChannel(      name: kJSChannelName,      onMessageReceived: (JavascriptMessage message) {        // 将JSON字符串转成Map        LoggerManager().debug("onMessageReceived message:${message.message}");        flutterFlushMessageQueue();      },    );  }  void updateController(WebViewController controller, BuildContext context) {    this.webController = controller;    this.context = context;  }  JavascriptChannel getJSChannel() {    return javascriptChannel!;  }  // 处理消息队列  void flutterFlushMessageQueue() async {    // 获取h5发送的列表    // 处理H5存的消息队列发送的MessageQueue    String? messageQueueString = await webController        ?.runJavascriptReturningResult(webViewJavascriptFetchQueyCommand());    LoggerManager().debug("flutterFlushMessageQueue:${messageQueueString}");    flushMessageQueue(messageQueueString);  }  // 处理来自H5的消息列表  void flushMessageQueue(String? messageQueueString) {    if (!(messageQueueString != null && messageQueueString.isNotEmpty)) {      return;    }    LoggerManager().debug(        "flushMessageQueue messageQueueString:${messageQueueString}");    dynamic? aFromH5Messages = jsonDecode(messageQueueString);    LoggerManager().debug(        "flushMessageQueue 1111 aFromH5Messages:${aFromH5Messages}, type:${aFromH5Messages.runtimeType}");    if (aFromH5Messages != null && aFromH5Messages is String) {      aFromH5Messages = jsonDecode(aFromH5Messages);    }    LoggerManager().debug(        "flushMessageQueue 222 aFromH5Messages:${aFromH5Messages}, type:${aFromH5Messages.runtimeType}");    if (aFromH5Messages != null && aFromH5Messages is List) {      for (dynamic aMsgJson in aFromH5Messages) {        if (aMsgJson is Map<String, dynamic>) {          JSMessage jsMessage = JSMessage.fromJson(aMsgJson);          LoggerManager().debug(              "flushMessageQueue aFromH5Messages aMsgJson:${aMsgJson} jsMessage:${jsMessage}");          // 从H5获取或者接收到的消息,如果responseId不为空,则为flutter调用H5方法,H5给flutter的回调          if (jsMessage.responseId != null &&              jsMessage.responseId!.isNotEmpty) {            // 如果responseId不为空,则为flutter调用H5方法,H5给flutter的回调            ResponseCallback? responseCallback =                responseCallbacks[jsMessage.responseId];            if (responseCallback != null) {              // 处理H5返回给flutter的回调              responseCallback(jsMessage.responseData);            }          } else {            ResponseCallback? responseCallback;            // 如果responseId为空时候,则是来自H5发送的flutter的消息            // 获取H5传过来的标识callbackId            String? callbackId = jsMessage.callbackId;            if (callbackId != null && callbackId.isNotEmpty) {              // 接收到来自H5的消息              JSMessage aMessage = JSMessage();              aMessage.copy(aNewMessage: aMessage, aOldMessage: jsMessage);              responseCallback = (dynamic responseData) {                // flutter回调给H5                // 将H5传过来的callbackId作为responseId回调传递给H5                aMessage.responseId = callbackId;                aMessage.responseData = responseData;                _queueMessage(aMessage);              };            } else {              responseCallback = (dynamic responseData) {                // callbackId为空,不做任何处理              };            }            // 从flutter已经注册Register方法中找出对应的方法            JSBridgeHandler? jsBridgeHandler =                messageHandlers[jsMessage.handlerName];            if (jsBridgeHandler != null) {              // 在flutter该handlerName的方法已经注册register              jsBridgeHandler(jsMessage.data, responseCallback);            } else {              // 在flutter该handlerName没有注册,则不做任何处理            }          }        }      }    }  }  // 处理从H5收到的消息  void _dispatchMessage(JSMessage message) async {    String messageJSON = jsonEncode(message.toJson());    messageJSON = messageJSON.replaceAll("\\", "\\\\");    messageJSON = messageJSON.replaceAll("\"", "\\\"");    messageJSON = messageJSON.replaceAll("\'", "\\\'");    messageJSON = messageJSON.replaceAll("\n", "\\n");    messageJSON = messageJSON.replaceAll("\r", "\\r");    messageJSON = messageJSON.replaceAll("\f", "\\f");    messageJSON = messageJSON.replaceAll("\u2028", "\\u2028");    messageJSON = messageJSON.replaceAll("\u2029", "\\u2029");    String javascriptCommand =        webViewJavascriptHandleMessageFromObjCCommand(messageJSON);    await webController?.runJavascript(javascriptCommand);  }  // 注入js  void injectJavascript(String javascript) async {    await webController?.runJavascript(javascript);  }  // 注入js  void injectJavascriptReady() async {    await webController?.runJavascript('javascript:$kWebviewJsBridgeReady');  }  // 注入js  void injectBridgeJavascript() async {    await webController?.runJavascript('javascript:$kWebviewJavascriptBridge');    LoggerManager().debug("injectJavascript");    // 处理flutter发送的消息队列    if (startupMessageQueue != null && startupMessageQueue!.isNotEmpty) {      List<JSMessage> tmpList = startupMessageQueue!;      startupMessageQueue = null;      for (JSMessage message in tmpList) {        _dispatchMessage(message);      }    }  }  // 向H5发送消息  void _sendData(String handleName,      {dynamic? data, ResponseCallback? responseCallback}) {    String callbackId = "flutter_cb_${++_uniqueId}";    JSMessage jsMessage = JSMessage();    jsMessage.callbackId = callbackId;    jsMessage.handlerName = handleName;    jsMessage.data = data;    // 将callbackId存储到responseCallbacks中,callbackId会被H5通过responseId返回    if (responseCallback != null) {      responseCallbacks[callbackId] = responseCallback;    }    _queueMessage(jsMessage);  }  // 将发送给H5的消息存到startupMessageQueue中  void _queueMessage(JSMessage jsMessage) {    if (startupMessageQueue != null) {      startupMessageQueue!.add(jsMessage);    }    _dispatchMessage(jsMessage);  }  // 判断是否可以注入url  bool isWebViewJavascriptBridgeURL(String url) {    if (!isSchemeMatch(url)) {      return false;    }    return isBridgeLoadedURL(url) || isQueueMessageURL(url);  }  bool isSchemeMatch(String url) {    String lowerUrl = url.toLowerCase();    LoggerManager().debug("isSchemeMatch lowerUrl:${lowerUrl}");    return (lowerUrl.startsWith(kNewProtocolScheme) ||        lowerUrl.startsWith(kOldProtocolScheme));  }  bool isQueueMessageURL(String url) {    String lowerUrl = url.toLowerCase();    LoggerManager().debug("isQueueMessageURL lowerUrl:${lowerUrl}");    return (isSchemeMatch(url) && (lowerUrl.contains(kQueueHasMessage)));  }  bool isBridgeLoadedURL(String url) {    String lowerUrl = url.toLowerCase();    LoggerManager().debug("isBridgeLoadedURL lowerUrl:${lowerUrl}");    return (isSchemeMatch(url) && (lowerUrl.contains(kBridgeLoaded)));  }  // 注入js的command  String webViewJavascriptCheckCommand() {    return "typeof window.AppJSBridge == \'object\';";  }  String webViewJavascriptFetchQueyCommand() {    return "AppJSBridge._fetchQueue();";  }  String webViewJavascriptHandleMessageFromObjCCommand(String messageJSON) {    return "AppJSBridge._handleMessageFromObjC('${messageJSON}');";  }  // 判断AppJSBridge  Future<String?> checkJavascriptBridge() async {    String? result = await webController        ?.runJavascriptReturningResult(webViewJavascriptCheckCommand());    LoggerManager().debug("checkJavascriptBridge result:${result}");    return result;  }  /// flutter开放出去的方法,flutter调用H5方法统一使用该callHandler  /// callHandler  void callHandler(String handleName,      {dynamic? data, ResponseCallback? responseCallback}) {    if (handleName.isNotEmpty) {      _sendData(handleName, data: data, responseCallback: responseCallback);    }  }  /// flutter注册方法  /// flutter注册方法,提供给H5调用  void registerHandler(String handleName, JSBridgeHandler jsBridgeHandler) {    if (handleName.isNotEmpty) {      messageHandlers[handleName] = jsBridgeHandler;    }  }  // 移除注册的方法  void removeHandler(String handleName) {    if (handleName.isNotEmpty) {      messageHandlers.remove(handleName);    }  }  // 重置,将responseCallbacks、startupMessageQueue重置  void reset() {    startupMessageQueue = [];    responseCallbacks = {};    _uniqueId = 0;  }}

2.6、JSChannelRegister:appBridge调用的方法,flutter注册的方法

JSChannelRegister实现处理flutter注册的方法,提供相应的方法,H5端的JS可以方便调用。

// appBridge调用的方法,flutter注册的方法class JSChannelRegister {  late JSChannelManager _jsChannelManager;  // 支付  final ChannelPayPlatform _channelPayPlatform = ChannelPayPlatform();  // 打开app等  final ChannelLauncher _channelLauncher = ChannelLauncher();  // 弹窗  final ChannelDialog _channelDialog = ChannelDialog();  // 扫码或者识别二维码  final ChannelQrScanner _channelQrScanner = ChannelQrScanner();  JSChannelRegister({required JSChannelManager jsChannelManager}) {    _jsChannelManager = jsChannelManager;  }  // 注册handlers  void registerHandlers({JSChannelRegisterHandler? jsChannelRegisterHandler}) {    // 设置标题    _jsChannelManager.registerHandler(JSChannelRegisterMethod.setTitle,        (data, responseCallback) {      if (data != null && data is String) {        String title = data;        if (jsChannelRegisterHandler != null) {          jsChannelRegisterHandler(JSChannelRegisterMethod.setTitle, title);        }      }    });    // 获取用户昵称    _jsChannelManager.registerHandler(JSChannelRegisterMethod.getUsername,        (data, responseCallback) {      UserModel userModel =          Provider.of<UserModel>(OneContext().context!, listen: false);      String userNickName = userModel.userNickName ?? "";      if (responseCallback != null) {        responseCallback(userNickName);      }    });    // 获取定位    _jsChannelManager.registerHandler(JSChannelRegisterMethod.getLoc,        (data, responseCallback) {      // TODO 获取定位    });    // 获取App名称    _jsChannelManager.registerHandler(JSChannelRegisterMethod.getAppName,        (data, responseCallback) {      PackageInfo.fromPlatform().then((packageInfo) {        String appName = "${packageInfo.appName}";        if (responseCallback != null) {          responseCallback(appName);        }      });    });    // 获取版本号    _jsChannelManager.registerHandler(JSChannelRegisterMethod.getVersion,        (data, responseCallback) {      PackageInfo.fromPlatform().then((packageInfo) {        String version = "${packageInfo.buildNumber}";        String versionCode = version.replaceAll(".", "");        if (responseCallback != null) {          responseCallback(versionCode);        }      });    });    // 获取用户id    _jsChannelManager.registerHandler(JSChannelRegisterMethod.getUserId,        (data, responseCallback) {      UserModel userModel =          Provider.of<UserModel>(OneContext().context!, listen: false);      String userId = userModel.userId ?? "";      if (responseCallback != null) {        responseCallback(userId);      }    });    // 获取用户登录认证token    _jsChannelManager.registerHandler(JSChannelRegisterMethod.getAuthorization,        (data, responseCallback) {      UserModel userModel =          Provider.of<UserModel>(OneContext().context!, listen: false);      String token = userModel.token ?? "";      if (responseCallback != null) {        responseCallback(token);      }    });    // 调用支付(微信支付/支付宝支付)原生    _jsChannelManager.registerHandler(JSChannelRegisterMethod.setPayPlatform,        (data, responseCallback) {      _channelPayPlatform.openUniPay(data, responseCallback);    });    // 打开扫一扫    _jsChannelManager.registerHandler(JSChannelRegisterMethod.openScan,        (data, responseCallback) {      // 打开扫一扫界面      _channelQrScanner.openScanner(          JSChannelRegisterMethod.openScan, data, responseCallback);    });    // 打开扫一扫    _jsChannelManager.registerHandler(JSChannelRegisterMethod.scanQrCode,        (data, responseCallback) {      // 打开扫一扫界面      _channelQrScanner.openScanner(          JSChannelRegisterMethod.scanQrCode, data, responseCallback);    });    // 打系统电话    _jsChannelManager.registerHandler(JSChannelRegisterMethod.callTelPhone,        (data, responseCallback) {      _channelLauncher.openLauncher(          JSChannelRegisterMethod.callTelPhone, data, responseCallback);    });    // 发送短信    _jsChannelManager.registerHandler(JSChannelRegisterMethod.sendSms,        (data, responseCallback) {      _channelLauncher.openLauncher(          JSChannelRegisterMethod.sendSms, data, responseCallback);    });    // 对话框 showDialog    _jsChannelManager.registerHandler(JSChannelRegisterMethod.showDialog,        (data, responseCallback) {      _channelDialog.openShowDialog(data, responseCallback);    });    // 底部选择框    _jsChannelManager.registerHandler(JSChannelRegisterMethod.showCheckBox,        (data, responseCallback) {      _channelDialog.openShowSheetBox(data, responseCallback);    });    // 保存图片到相册    _jsChannelManager.registerHandler(JSChannelRegisterMethod.saveImage,        (data, responseCallback) {      // 保存图片到相册      if (data != null && data is String && data.isNotEmpty) {        FlutterLoadingHud.showLoading(message: "保存中...");        SaveToAlbumUtil.saveImage(data, onCallback: (bool result, String message) {          FlutterLoadingHud.dismiss();          if (result) {            // 保存成功            FlutterLoadingHud.showToast(message: message);          } else {            // 保存失败            FlutterLoadingHud.showToast(message: message);          }        });      }    });    // 识别二维码    _jsChannelManager.registerHandler(JSChannelRegisterMethod.detectorQRCode,        (data, responseCallback) {      // 识别图片中的二维码      _channelQrScanner.openScanner(          JSChannelRegisterMethod.detectorQRCode, data, responseCallback);    });    // 打开App    _jsChannelManager.registerHandler(JSChannelRegisterMethod.openApp,        (data, responseCallback) {      _channelLauncher.openLauncher(          JSChannelRegisterMethod.openApp, data, responseCallback);    });    // log    _jsChannelManager.registerHandler(JSChannelRegisterMethod.log,        (data, responseCallback) {      Map<String, dynamic> dataJson = jsonDecode(data);      int loggerType = dataJson["logType"];      String message = dataJson["message"];      if (LoggerMode.debug == loggerType) {        LoggerManager().debug("registerHandlers log data: ${message}");      } else if (LoggerMode.verbose == loggerType) {        LoggerManager().verbose("registerHandlers log data: ${message}");      } else if (LoggerMode.info == loggerType) {        LoggerManager().info("registerHandlers log data: ${message}");      } else if (LoggerMode.warning == loggerType) {        LoggerManager().warning("registerHandlers log data: ${message}");      } else if (LoggerMode.error == loggerType) {        LoggerManager().error("registerHandlers log data: ${message}");      }    });  }  // 处理是否跳转,true可跳转,false不可跳转  bool navigationDecision(NavigationRequest request) {    ///在页面跳转之前调用 isForMainFrame为false,页面不跳转.导致网页内很多链接点击没效果    String url = Uri.decodeComponent(request.url);    LoggerManager().debug('navigationDelegate decode $url');    String telPrefix = "tel://";    String smsPrefix = "sms://";    String appPrefix = "app://";    if (url.startsWith(telPrefix)) {      String data = url.substring(telPrefix.length);      _channelLauncher.openLauncher(          JSChannelRegisterMethod.callTelPhone, data, null);      // 不可跳转      return false;    }    if (url.startsWith(smsPrefix)) {      String data = url.substring(smsPrefix.length);      _channelLauncher.openLauncher(          JSChannelRegisterMethod.sendSms, data, null);      // 不可跳转      return false;    }    if (url.startsWith(appPrefix)) {      // app://close      _channelLauncher.openappUrl(url);      return false;    }    if (url == "about:blank") {      // 空页面进行跳转      return true;    }    // 可跳转    return true;  }}

使用JSChannelRegister,处理相应的callback

  void initState() {    // TODO: implement initState    super.initState();    _isDisposed = false;    _jsChannelRegister = JSChannelRegister(jsChannelManager: _jsChannelManager);    _jsChannelRegister.registerHandlers(        jsChannelRegisterHandler: (handlerName, data) {      if (JSChannelRegisterMethod.setTitle == handlerName) {        setWebPageTitle(data);      }    });  }

2.7、JSMessage:H5和flutter交互的消息体

class JSMessage {  // {handlerName: getSessionID, data: , callbackId: cb_2_1665631238605}  // handlerName  String? handlerName;  // data  // flutter发送给H5的data,参数  dynamic? data;  /// callbackId,  /// H5发送给flutter的callbackId,  /// flutter处理后将调用 AppJSBridge._handleMessageFromObjC('%@');  /// H5从responseCallbacks中根据callbackId找到callback回调方法进行执行  String? callbackId;  /// responseId  /// flutter发送给H5的responseId,  /// responseId和callbackId是一样的  /// 如果是H5调用flutter时候,从H5过来的callbackId作为responseId回调给H5  /// 如果是flutter调用H5,从flutter过来的callbackId作为responseId回调给flutter  String? responseId;  /// 回调的数据  /// 如果是H5调用flutter时候,从flutter传给H5的responseData作为回调数据  /// 如果是flutter调用H5,从H5传给flutter的responseData作为回调数据  dynamic? responseData;  JSMessage();  JSMessage.fromJson(Map<String, dynamic> json) {    callbackId = json['callbackId'];    data = json['data'];    handlerName = json['handlerName'];    responseId = json['responseId'];    responseData = json['responseData'];  }  Map<String, dynamic> toJson() {    final Map<String, dynamic> data = new Map<String, dynamic>();    data['callbackId'] = this.callbackId;    data["data"] = this.data;    data["handlerName"] = this.handlerName;    data['responseId'] = this.responseId;    data['responseData'] = this.responseData;    return data;  }  void copy({required JSMessage aNewMessage, required JSMessage aOldMessage}) {    aNewMessage.callbackId = aOldMessage.callbackId;    aNewMessage.data = aOldMessage.data;    aNewMessage.handlerName = aOldMessage.handlerName;    aNewMessage.responseId = aOldMessage.responseId;    aNewMessage.responseData = aOldMessage.responseData;  }}

三、H5前端

我这里使用的是本地Html文件,在JS中调用window.AppJSBridge中的方法,如callHandler、registerHandler。
Html示例代码

<!DOCTYPE html><html> <head>   <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0, maximum-scale=1.0" />   <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />  <style type="text/css">                body{                    background: #f5faff;                }            .button{                width: 100%;                line-height: 38px;                text-align: center;                font-weight: bold;                color: #fff;                text-shadow:1px 1px 1px #333;                margin:0 auto;            }            .button:nth-child(6n){                margin-right: 0;            }            .button.gray{                color: #8c96a0;                text-shadow:1px 1px 1px #fff;                border:1px solid #dce1e6;                box-shadow: 0 1px 2px #fff inset,0 -1px 0 #a8abae inset;                background: -webkit-linear-gradient(top,#f2f3f7,#e4e8ec);                background: -moz-linear-gradient(top,#f2f3f7,#e4e8ec);                background: linear-gradient(top,#f2f3f7,#e4e8ec);            }            </style>  <title>   JSBridge调用示例,常用方法调用  </title> </head>  <body>  <button type="button" class="button gray" id="getUsername">getUsername</button>  <button type="button" class="button gray" id="getLoc">getLoc</button>  <button type="button" class="button gray" id="getVersion">getVersion</button>  <button type="button" class="button gray" id="scanQrCode">scanQrCode</button>  <button type="button" class="button gray" id="setMenuItems">setMenuItems</button>  <button type="button" class="button gray" id="callTelPhone">callTelPhone</button>  <button type="button" class="button gray" id="webImagePreview">webImagePreview</button>  <button type="button" class="button gray" id="showCheckBox">showCheckBox</button>  <button type="button" class="button gray" id="showDialog">showDialog</button>  <button type="button" class="button gray" id="saveImage">saveImage</button>  <button type="button" class="button gray" id="openApp">打开其他App</button>  <script>        var imgURL = 'http://tupian.qqjay.com/tou3/2016/0726/fc4fe6f04843172bd6dbfeb5b6fe0686.jpg';        var title = '分享券'        var desc = '分享券描述内容'        var url = 'http://www.laileshuo.com'        var wxSharedObject = {            thumb: imgURL,            title: title,            desc: desc,            url: url        };        var appSharedObject = {            thumb: imgURL,            title: title,            desc: desc,            url: url        };                    var getUsername=document.getElementById("getUsername");        getUsername.addEventListener('click',function(){            AppJSBridge.callHandler('getUsername', '',  function(response) {                window.alert(response)            });        });                var getLoc=document.getElementById("getLoc");        getLoc.addEventListener('click',function(){            AppJSBridge.callHandler('getLoc', '',  function(response) {                window.alert(response)            });        });        var getVersion=document.getElementById("getVersion");        getVersion.addEventListener('click',function(){            AppJSBridge.callHandler('getVersion', '',  function(response) {                window.alert(response)            });        });        var scanQrCode=document.getElementById("scanQrCode");        scanQrCode.addEventListener('click',function(){            AppJSBridge.callHandler('scanQrCode', '',  function(response) {                window.alert(response)            });        });        var setMenuItems=document.getElementById("setMenuItems");        setMenuItems.addEventListener('click',function(){            AppJSBridge.callHandler('setMenuItems', 'wxinFreind,wxinTime,weibo,refresh',  function(response) {});        });        var callTelPhone=document.getElementById("callTelPhone");        var telPhone = '10086,10086';        callTelPhone.addEventListener('click',function(){            AppJSBridge.callHandler('callTelPhone', telPhone,  function(response) {                // log('JS got response', response)            });        });        var webImagePreview=document.getElementById("webImagePreview");        var previewData = {            'imgs' : [                  //图片列表数组                'http://7sbytg.com1.z0.glb.clouddn.com/yz2.png',                'http://7sbytg.com1.z0.glb.clouddn.com/yz2.png'            ],            'index' : '0'               //进入预览时显示第几个图片        };        webImagePreview.addEventListener('click',function(){            AppJSBridge.callHandler('webImagePreview', JSON.stringify(previewData),  function(response) {            });        });        var showCheckBox=document.getElementById("showCheckBox");        var bottomBox = {            'optionList' : ['删除', '兑换', '其他']       //选项列表,选项列表对应自己的index        };        showCheckBox.addEventListener('click',function(){            AppJSBridge.callHandler('showCheckBox', JSON.stringify(bottomBox),  function(response) {                window.alert(response)            });        });        var showDialog=document.getElementById("showDialog");        var dialog = {            'title' : '标题',             // Dialog标题            'message' : '对话框内容',      // Dialog内容,可选            'ok' : '确定',                // 确认按钮的文字,可选,不填时不显示该按钮            'cancel' : '取消'             // 取消按钮的文字,可选,不填时不显示该按钮        };        showDialog.addEventListener('click',function(){            AppJSBridge.callHandler('showDialog', JSON.stringify(dialog),  function(response) {                // log('JS got response', response)            });        });        var saveImage=document.getElementById("saveImage");        saveImage.addEventListener('click',function(){            AppJSBridge.callHandler('saveImage', 'https://c-ssl.duitang.com/uploads/item/201611/12/20161112230928_vJEQy.jpeg',  function(response) {});        });        var openApp=document.getElementById("openApp");        openApp.addEventListener('click',function(){            AppJSBridge.callHandler('openApp', 'weixin',  function(response) {            });        });                if (window.AppJSBridge) {            var dialog = {                'title' : '标题',             // Dialog标题                'message' : '对话框内容',      // Dialog内容,可选                'ok' : '确定',                // 确认按钮的文字,可选,不填时不显示该按钮                'cancel' : '取消'             // 取消按钮的文字,可选,不填时不显示该按钮            };            AppJSBridge.callHandler('showDialog', JSON.stringify(dialog),  function(response) {              // log('JS got response', response)            });        }                document.addEventListener('AppJSBridgeReady', function() {              AppJSBridge.registerHandler('JSAPPHandler', function(data, responseCallback) {                 var responseData = { 'Javascript Says':'Right back atcha!' }                 responseCallback(responseData)             });                  var dialog = {                  'title' : '标题',             // Dialog标题                  'message' : '对话框内容',      // Dialog内容,可选                  'ok' : '确定',                // 确认按钮的文字,可选,不填时不显示该按钮                  'cancel' : '取消'             // 取消按钮的文字,可选,不填时不显示该按钮              };              AppJSBridge.callHandler('showDialog', JSON.stringify(dialog),  function(response) {                // log('JS got response', response)              });        }, false);                //WKWebView 可用        document.addEventListener('visibilitychange', () => {              if (document.hidden) {                  // 页面被挂起                  window.alert(document.visibilityState)              } else {                  // 页面呼出                  window.alert(document.visibilityState)              }        })            </script> </body></html>

四、flutter的webView_page页面打开对应的Html页面

这里使用的JSChannelManager、JSCookieConfig、JSChannelRegister等flutter

WebViewPage

class WebViewPage extends StatefulWidget {  const WebViewPage({    Key? key,    this.arguments,  }) : super(key: key);  final Object? arguments;    State<WebViewPage> createState() => _WebViewPageState();}class _WebViewPageState extends State<WebViewPage> {  String title = "";  String? url;  // WebViewController  WebViewController? _webViewController;  double webProgress = 0.0;    void initState() {    // TODO: implement initState    if (widget.arguments != null && widget.arguments is Map) {      Map obj = widget.arguments as Map;      url = obj["url"];    }    LoggerManager().debug("_WebViewPageState arguments:${widget.arguments}");    LoggerManager().debug("_WebViewPageState url:${url}");    super.initState();  }    void dispose() {    // TODO: implement dispose    super.dispose();  }    Widget build(BuildContext context) {    return Scaffold(      appBar: WebAppBar(        toolbarHeight: 44.0,        backgroundColor: Theme.of(context).primaryColor,        centerWidget: Text(          title,          textAlign: TextAlign.center,          overflow: TextOverflow.ellipsis,          style: TextStyle(            fontSize: 17,            color: ColorUtil.hexColor(0xffffff),            fontWeight: FontWeight.w600,            fontStyle: FontStyle.normal,            decoration: TextDecoration.none,          ),        ),        leadingWidget: Row(          mainAxisAlignment: MainAxisAlignment.start,          crossAxisAlignment: CrossAxisAlignment.center,          children: [            IconButton(              padding: EdgeInsets.all(0.0),              onPressed: () {                webViewGoBack();              },              icon: Icon(                Icons.arrow_back_ios,                color: Colors.white,                size: 24.0,              ),            ),            IconButton(              padding: EdgeInsets.all(0.0),              onPressed: () {                navigatorBack();              },              icon: Icon(                Icons.close_rounded,                color: Colors.white,                size: 30.0,              ),            ),          ],        ),        trailingWidget: Row(          mainAxisAlignment: MainAxisAlignment.end,          crossAxisAlignment: CrossAxisAlignment.center,          children: [            SizedBox(              width: 28.0,            ),            IconButton(              padding: EdgeInsets.all(0.0),              onPressed: () {                webViewReload();              },              icon: Icon(                Icons.refresh_outlined,                color: Colors.white,                size: 28.0,              ),            ),          ],        ),      ),      body: Stack(        children: [          WebViewSkeleton(            url: url ?? "",            onWebResourceError: (WebResourceError error) {              if (mounted) {                // TODO onWebResourceError              }            },            onWebProgress: (int progress) {              if (mounted) {                // TODO onWebProgress                double precent = progress / 100.0;                if (precent > 1.0) {                  precent = 1.0;                }                if (precent < 0.0) {                  precent = 0.0;                }                setState(() {                  webProgress = precent;                  LoggerManager().debug("webProgress:${webProgress}");                });              }            },            onLoadFinished: (String? url) {              if (mounted) {                // TODO onLoadFinished              }            },            onWebTitleLoaded: (String? webTitle) {              if (mounted) {                setState(() {                  title = webTitle ?? "";                });              }            },            onWebViewCreated: (WebViewController controller) {              _webViewController = controller;            },          ),          buildProgressIndicator(context),        ],      ),    );  }    Widget buildProgressIndicator(BuildContext context) {    return (webProgress != 1.0)        ? LinearProgressIndicator(      backgroundColor: Colors.transparent,      valueColor:      AlwaysStoppedAnimation(ColorUtil.hexColor(0x3b93ff)),      value: webProgress,      minHeight: 2,    )        : Container();  }  void navigatorBack() {    NavigatorPageRouter.pop();  }  void webViewGoBack() {    _webViewController?.canGoBack().then((res) {      // 是否能返回上一级      LoggerManager().debug("controller.canGoBack res: $res");      if (true == res) {        _webViewController?.goBack();      } else {        navigatorBack();      }    });  }  void webViewReload() {    _webViewController?.reload();  }}

WebViewSkeleton

class WebViewSkeleton extends StatefulWidget {  const WebViewSkeleton({    Key? key,    required this.url,    required this.onWebProgress,    required this.onWebResourceError,    required this.onLoadFinished,    this.onWebTitleLoaded,    required this.onWebViewCreated,  }) : super(key: key);  final String url;  final Function(int progress) onWebProgress;  final Function(WebResourceError error) onWebResourceError;  final Function(String? url) onLoadFinished;  final Function(String? webTitle)? onWebTitleLoaded;  final Function(WebViewController controller) onWebViewCreated;  static GlobalKey<_WebViewSkeletonState> getGlobalKey() => GlobalKey();    State<WebViewSkeleton> createState() => _WebViewSkeletonState();}class _WebViewSkeletonState extends State<WebViewSkeleton> {  // WebViewController  WebViewController? _webController;  // JS与Flutter调用的message Queue  final JSChannelManager _jsChannelManager = JSChannelManager();  // cookie  final JSCookieConfig _jsCookieConfig = JSCookieConfig();  // flutter注册供H5调用的方法  late JSChannelRegister _jsChannelRegister;  // 尝试3次,每次间隔2秒  int _loadTitleTimes = 0;  bool _isDisposed = false;    void initState() {    // TODO: implement initState    super.initState();    _isDisposed = false;    _jsChannelRegister = JSChannelRegister(jsChannelManager: _jsChannelManager);    _jsChannelRegister.registerHandlers(        jsChannelRegisterHandler: (handlerName, data) {      if (JSChannelRegisterMethod.setTitle == handlerName) {        setWebPageTitle(data);      }    });  }    void dispose() {    // TODO: implement dispose    _isDisposed = true;    _jsChannelManager.reset();    _webController?.clearCache();    // _jsCookieConfig.clear();    super.dispose();  }  // flutter调用H5方法  void callJSMethod() {    _jsChannelManager.callHandler("JSAPPHandler", data: {"id": "a18c9fe0d"},        responseCallback: (dynamic responseData) {      LoggerManager().debug("callJSMethod responseData:${responseData}");      FlutterLoadingHud.showToast(message: jsonEncode(responseData));    });  }  void webPageLoadedStart() {    _loadTitleTimes = 0;  }  Future<void> getWebPageTitle({required String url}) async {    if (_isDisposed) {      return;    }    String? title = await _webController?.getTitle();    LoggerManager().debug("getWebPageTitle:${title}");    if (title != null && title.isNotEmpty) {      LoggerManager().debug("webTitle a:${title}");      setWebPageTitle(title);    } else {      try {        String? result = await _webController            ?.runJavascriptReturningResult('window.document.title');        LoggerManager().debug("webTitle document.url:${result}");        if (result != null && result.isNotEmpty) {          setWebPageTitle(result);        } else {          result = await _webController?.runJavascriptReturningResult(              'window.document.getElementsByTagName("title")[0]');          LoggerManager()              .debug("webTitle document.getElementsByTagName:${result}");          setWebPageTitle(result);        }      } catch (e) {        print("getWebPageTitle:${e.toString()}");        // 最多尝试三次        if (_loadTitleTimes < 3) {          Future.delayed(Duration(seconds: 2), () {            _loadTitleTimes++;            getWebPageTitle(url: url);          });        }      }    }  }  // 设置页面标题  void setWebPageTitle(data) {    if (widget.onWebTitleLoaded != null) {      widget.onWebTitleLoaded!(data);    }  }  // 返回  void goBack() {    _webController?.canGoBack().then((res) {      // 是否能返回上一级      LoggerManager().debug("controller.canGoBack res: $res");      if (true == res) {        _webController?.goBack();      }    });  }  // 刷新  void reload() {    _webController?.reload();  }    Widget build(BuildContext context) {    return buildWebView(context);  }  Widget buildWebView(BuildContext context) {    UserModel userModel = Provider.of<UserModel>(context, listen: false);    LoggerManager().debug("ApiAuth().token:${ApiAuth.getToken()}");    return WebView(      debuggingEnabled: true,      initialUrl: widget.url,      javascriptMode: JavascriptMode.unrestricted,      userAgent: "app-yjxdh-webview",      initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow,      allowsInlineMediaPlayback: true,      initialCookies: _jsCookieConfig.initialCookies(),      onWebViewCreated: (controller) {        LoggerManager().debug("onWebViewCreated");        _jsCookieConfig.setCookies();        // controller.loadUrl(url);此时也可以初始化一个url        controller.canGoBack().then((res) {          // 是否能返回上一级          LoggerManager().debug("controller.canGoBack res: $res");        });        controller.currentUrl().then((url) {          // 返回当前url          LoggerManager().debug("controller.currentUrl url: $url");        });        controller.canGoForward().then((res) {          //是否能前进          LoggerManager().debug("controller.canGoForward res: $res");        });        _webController = controller;        _jsChannelManager.updateController(controller, context);        String filePre = "file://";        if (widget.url.startsWith(filePre)) {          String html = widget.url.substring(filePre.length);          DefaultAssetBundle.of(context)              .loadString('assets/htmls/${html}')              .then((value) => _webController?.loadHtmlString(value));        } else {          if (widget.url.startsWith("http://") ||              widget.url.startsWith("https://")) {            _webController?.loadUrl(widget.url, headers: {              'Referer': widget.url,            });          }        }        // 注入jsReady        _jsChannelManager.injectJavascriptReady();        widget.onWebViewCreated(controller);      },      onProgress: (int progress) {        widget.onWebProgress(progress);      },      javascriptChannels: <JavascriptChannel>{        _jsChannelManager.javascriptChannel!,      },      navigationDelegate: (NavigationRequest request) {        bool canNavigate = _jsChannelRegister.navigationDecision(request);        // 允许路由替换        return canNavigate            ? NavigationDecision.navigate            : NavigationDecision.prevent;      },      onPageStarted: (String url) {        // 网页开始加载        webPageLoadedStart();        LoggerManager().debug('onPageStarted url: $url');      },      onPageFinished: (String url) {        // 网页加载完成        LoggerManager().debug('onPageFinished url: $url');        // 注入        _jsChannelManager.injectBridgeJavascript();        _jsChannelManager.checkJavascriptBridge();        // 加载完成        widget.onLoadFinished(url);        // 获取网页的标题        getWebPageTitle(url: url);      },      gestureNavigationEnabled: true,      backgroundColor: ColorUtil.hexColor(0xf7f7f7),      onWebResourceError: (WebResourceError error) {        /// error        LoggerManager().debug("onWebResourceError:${error}");        widget.onWebResourceError(error);      },    );  }  Widget buildButtonRow(BuildContext context) {    return Row(      mainAxisAlignment: MainAxisAlignment.end,      crossAxisAlignment: CrossAxisAlignment.center,      children: [        buildButton(context),        SizedBox(          width: 10.0,        ),        buildRefreshButton(context),      ],    );  }  // 展开的按钮  Widget buildButton(BuildContext context) {    return Container(      decoration: BoxDecoration(        color: Colors.white,        border: Border.all(          color: Colors.black26,          width: 1.0,          style: BorderStyle.solid,        ),        borderRadius: BorderRadius.all(          Radius.circular(8.0),        ),      ),      child: TextButton(        onPressed: () {          callJSMethod();        },        child: Text(          '调用JS方法菜单',          style: TextStyle(            fontSize: 12,            color: Colors.black,          ),        ),      ),    );  }  // 刷新按钮  Widget buildRefreshButton(BuildContext context) {    return Container(      decoration: BoxDecoration(        color: Colors.white,        border: Border.all(          color: Colors.black26,          width: 1.0,          style: BorderStyle.solid,        ),        borderRadius: BorderRadius.all(          Radius.circular(8.0),        ),      ),      child: TextButton(        onPressed: () {          reload();        },        child: Text(          '刷新WebView',          style: TextStyle(            fontSize: 12,            color: Colors.black,          ),        ),      ),    );  }}

六、运行效果图

在这里插入图片描述

在这里插入图片描述

五、小结

flutter开发实战-webview_flutter结合javascriptbridge实现flutter与html交互,通过使用flutter webview通过javascriptBridge来进行交互、交互用到了JavascriptChannel、cookie等。代码是好久之前写的,现在文档整理的有点乱,代码中基本上都有注释。希望有对你有用的点。

学习记录,每天不停进步。

来源地址:https://blog.csdn.net/gloryFlow/article/details/131683122

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     807人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     351人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     314人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     433人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     221人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯