An article to get "Playing with WebView"

foreword

First of all, this article is a foreshadowing for the optimization of the next WebView. So this article is relatively easy, and it's all about how to use it, a nanny-level introductory tutorial.

Android WebView is a special View on the Android platform, which can be used to display web pages.

Next, we also briefly understand the contents of the following three parts:

  • Common methods of WebView
  • Commonly used subclasses of WebView (WebSettings class, WebViewClient class, WebChromeClient class)
  • Interaction between WebView and JavaScript
  • Common problems in development

Common methods of WebView

State-related methods of WebView

//激活WebView为活跃状态,能正常执行网页的响应

webView.onResume()

//当页面被失去焦点被切换到后台不可见状态,需要执行onPause()
//通过onPause()动作通知内核暂停所有的动作,比如DOM的解析、JavaScript执行等

webView.onPause()

//当应用程序(存在webview)被切换到后台时,这个方法不仅仅针对当前的webview而是全局的全应用程序的webview
 //它会暂停所有webview的布局显示、解析、延时,从而降低CPU功耗
webView.pauseTimers()

//恢复pauseTimers状态

webView.resumeTimers()

//销毁Webview //在关闭了Activity时,如果Webview的音乐或视频,还在播放,就必须销毁Webview。
//但是注意:webview调用destory时,webview仍绑定在Activity上
//这是由于自定义webview构建时传入了该Activity的context对象
//因此需要先从父容器中移除webview,然后再销毁webview
rootLayout.removeView(webView);
webView.destroy();

WebView forward and backward

//是否可以后退
Webview.canGoBack()

//后退网页
Webview.goBack()

//是否可以前进
Webview.canGoForward()

//前进网页
Webview.goForward()

//以当前的index为起始点前进或者后退到历史记录中指定的steps
//如果steps为负数则为后退,正数则为前进
Webview.goBackOrForward(intsteps)

For example: when not doing any processing, when browsing the web, click the "Back" button of the system. It will exit the current Activity. So, here, we can do some processing, so that after clicking the "Back" button, let the webpage return to the previous page instead of directly exiting the browser.

public boolean onKeyDown(int keyCode, KeyEvent event) {
    
    
    if ((keyCode == KEYCODE_BACK) && mWebView.canGoBack()) {
    
    
         mWebView.goBack();
         return true;
    }
    return super.onKeyDown(keyCode, event);
}

WebView cache

//清除网页访问留下的缓存
//由于内核缓存是全局的因此这个方法不仅仅针对webview而是针对整个应用程序.
Webview.clearCache(true);

//清除当前webview访问的历史记录
//只会webview访问历史记录里的所有记录除了当前访问记录
Webview.clearHistory()

//这个api仅仅清除自动完成填充的表单数据,并不会清除WebView存储到本地的数据
Webview.clearFormData()

Commonly used subclasses of WebView

WebSettings class

Function: Configure and manage WebView, you can set the interaction with Javascript, adapt to the screen, set the cache mode, etc.

//声明WebSettings子类
WebSettings webSettings = webView.getSettings();

Some commonly used settings for WebView

//如果访问的页面中要与Javascript交互,则webview必须设置支持Javascript
webSettings.setJavaScriptEnabled(true);

//支持插件
webSettings.setPluginsEnabled(true);

 //设置自适应屏幕,两者合用
webSettings.setUseWideViewPort(true); //将图片调整到适合webview的大小
webSettings.setLoadWithOverviewMode(true); // 缩放至屏幕的大小

//缩放操作
webSettings.setSupportZoom(true); //支持缩放,默认为true。是下面那个的前提。
webSettings.setBuiltInZoomControls(true); //设置内置的缩放控件。若为false,则该WebView不可缩放
webSettings.setDisplayZoomControls(false); //隐藏原生的缩放控件

//webview中缓存设置
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK); 
    //LOAD_CACHE_ONLY: 不使用网络,只读取本地缓存数据
    //LOAD_DEFAULT: (默认)根据cache-control决定是否从网络上取数据。
    //LOAD_NO_CACHE: 不使用缓存,只从网络获取数据.
    //LOAD_CACHE_ELSE_NETWORK,只要本地有,无论是否过期,或者no-cache,都使用缓存中的数据

//设置可以访问文件
webSettings.setAllowFileAccess(true); 
//支持通过JS打开新窗口
webSettings.setJavaScriptCanOpenWindowsAutomatically(true); 
//支持自动加载图片
webSettings.setLoadsImagesAutomatically(true); 
//设置编码格式
webSettings.setDefaultTextEncodingName("utf-8");

WebViewClient class

Function: Mainly handle various events during the webView loading process, and intercept and process them, such as loading start, loading completion, loading error, page jump, etc.
Design Patterns: Using the Adapter Pattern

webView.setWebViewClient(new WebViewClient(){
    
    

	//shouldOverrideUrlLoading 则用于拦截 WebView 中的页面跳转请求并做相应处理
	//场景:可以用来拦截特定的 URL 请求,进行重定向或者其他业务逻辑处理,比如处理第三方登录、处理深链接跳转等
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
    
    
        // 重写shouldOverrideUrlLoading()方法,使得打开网页时不调用系统浏览器, 而是在本WebView中显示
        view.loadUrl(request.getUrl().toString());
        return true;
    }
    
    //shouldInterceptRequest 用于拦截 WebView 发起的网络请求并处理响应结果
    //场景:可以用来实现缓存机制、修改请求头、屏蔽广告等功能。
	@Nullable
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
    
    
         //拦截URL,进行解析
         Uri uri = request.getUrl();
         Set<String> collection = uri.getQueryParameterNames();
         new Handler(Looper.getMainLooper()).post(() ->{
    
    
              Toast.makeText(aaa.this, "哈哈哈" + collection.size(), Toast.LENGTH_SHORT).show();
         });
         return super.shouldInterceptRequest(view, request);
    }

    @Override
    public void onPageStarted(WebView view, String url, Bitmap favicon) {
    
    
        // 页面加载的开始,我们可以去启动进度条等加载动画
    }

    @Override
    public void onPageFinished(WebView view, String url) {
    
    
        // 页面加载结束,可以去关闭进度条等加载动画
    }

    @Override
    public void onLoadResource(WebView view, String url) {
    
    
        // 每次加载资源都会去调用
        // 可以对一些加载资源进行拦截
    }

    @Override
    public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
    
    
        // 加载页面的服务器出现错误时(如404)调用。
        // 通常出现错误的时候,不会给用户直接展示404。而是替换本地的错误展示页面
        // 比如本地的一个HTML(自己写一个HTML放在代码根目录的assets文件夹下)
        switch(errorCode) {
    
    
            case 404:
                view.loadUrl("file:///android_assets/error.html");
                break;
        }
    }

    @Override
    public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
    
    
        // 处理https请求
        // webView默认是不处理https请求的,页面显示空白
        handler.proceed(); //表示等待证书响应
        handler.cancel(); //表示挂起连接,为默认方式
        handler.handleMessage(null); //可做其他处理
    }
});

The points that need to be noted are: shouldInterceptRequest is used to intercept the network request initiated by WebView and process the response result, while shouldOverrideUrlLoading is used to intercept the page jump request in WebView and process it accordingly. The two are generally used together. By intercepting network requests and page jumps, more flexible WebView applications can be realized, such as customized caching, blocking advertisements, and processing specific URLs.

WebChromeClient class

Function: Mainly handle various events about the page in webView, such as page title change, progress change, pop-up window prompt, obtaining web page information, etc.
Design mode: Adapter mode is adopted

webView.setWebChromeClient(new WebChromeClient(){
    
    
    @Override
    public void onProgressChanged(WebView view, int newProgress) {
    
    
        //获得网页的加载进度并显示
        processView.set(newProgress);
    }

    //拦截JS中的三种弹窗
    @Override
    public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
    
    
        // 当网页调用alert()来弹出alert弹出框前回调,用以拦截alert()函数
        return true;
    }

    @Override
    public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
    
    
        // 当网页调用confirm()来弹出confirm弹出框前回调,用以拦截confirm()函数
        return true;
    }

    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    
    
        // 当网页调用prompt()来弹出prompt弹出框前回调,用以拦截prompt()函数
        return true;
    }

    @Override
    public void onReceivedTitle(WebView view, String title) {
    
    
        // 获取标题的
        titleView.setText(title);
    }
});

Interaction between WebView and JavaScript

The interaction between WebView and JS is a requirement that is often encountered in development. The following briefly introduces several ways of interaction.

JS calls Android

There are 3 ways to call Android code from JS:

  1. Object mapping through WebView's addJavascriptInterface()
  2. Intercept url through WebViewClient's shouldOverrideUrlLoading() method callback
  3. Intercept JS dialog alert(), confirm(), prompt() messages through WebChromeClient's onJsAlert(), onJsConfirm(), onJsPrompt() method callbacks

Object mapping through WebView's addJavascriptInterface()

Step 1: Prepare an HTML, of course it can be the delivered HTML, put it under app/src/main/assets/ as js.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>WebView</title>
    <style>
        body {
    
    
            background-color: #f2f2f2;
        }
    </style>
<!--由于对象映射,所以调用jsMethod对象等于调用Android映射的对象-->
    <script>
         function callAndroid(){
    
    
            jsMethod.hello("哈哈哈,笑死了");
         }
      </script>
</head>
<body>
<!--触发事件-->
<button type="button" id="button1" onclick="callAndroid()">点击按钮,笑死了</button>
</body>
</html>

Step 2: Define an Android class that maps to JS objects: JsMethod()

public class JsMethod{
    
    
    //定义JS需要调用的方法,被JS调用的方法必须加入@JavascriptInterface注解
    @JavascriptInterface
    public void hello(String msg){
    
    
        Toast.makeText(aaa.this, msg, Toast.LENGTH_SHORT).show();
    }
}

Step 3: Set the mapping between Android class and JS code through WebView in Android

WebSettings webSettings = webView.getSettings();
//设置与Js交互的权限
webSettings.setJavaScriptEnabled(true);
//通过addJavascriptInterface和@JavascriptInterface映射hello方法
webView.addJavascriptInterface(new JsMethod(), "jsMethod");
//加载本地的HTML
webView.loadUrl("file:///android_asset/js.html");

Intercept url through WebViewClient's shouldOverrideUrlLoading() method callback

The first step: still prepare an HTML, of course it can be the issued HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>SoarYuan</title>
<!--约定的url协议为:js://webview?arg1=哈哈哈&arg2=呜呜呜-->
    <script>
         function callAndroid(){
    
    
            document.location = "js://webview?arg1=哈哈哈&arg2=呜呜呜";
         }
      </script>
</head>

<!-- 点击按钮则调用callAndroid()方法  -->
<body>
<button type="button" id="button2" onclick="callAndroid()">点击按钮,调用Android</button>
</body>
</html>

Step 2: Override shouldOverrideUrlLoading() on Android through WebViewClient.
When the JS is loaded through Android's mWebView.loadUrl("file:///android_asset/javascript.html"), it will call back shouldOverrideUrlLoading().

webView.loadUrl("file:///android_asset/js2.html");
webView.setWebViewClient(new WebViewClient() {
    
    
    @Nullable
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request){
    
    
        //拦截URL,进行解析
        Uri uri = request.getUrl();
        Set<String> collection = uri.getQueryParameterNames();
        new Handler(Looper.getMainLooper()).post(() ->{
    
    
            Toast.makeText(aaa.this, "哈哈哈" + collection.size(), Toast.LENGTH_SHORT).show();
        });
        return super.shouldOverrideUrlLoading(view, request);
    }
});

Here we must focus on: shouldInterceptRequest interception
Because this is a routine, it is also a common practice.

WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request)

Let me briefly talk about the common methods in this project: using a specific schema URL:
To put it bluntly: it is a mechanism for communicating through a protocol protocol to realize two-way communication between the Native side and the Web side.
For example: agree on a fixed-format scheme agreement

[customscheme:][//methodName][?params={data, callback}]
customscheme:自定义需要拦截的scheme
methodName:需要调用的native的方法
params:传递给native的参数 和 回调函数名

Then we send according to this protocol:

jsbridge://showToast?text=hello

You can tell at a glance, this is to let you go to showToast, and then the content is hello.
Of course, this is a simple example. In the project, the protocol format will be defined according to the requirements and packaging (you can also directly use the JSbridge tripartite framework to interact).

It's also very simple, we only need to negotiate with JS, and then we can parse it according to the agreed protocol format when we get here, no more comparisons.
(Here I will briefly talk about it, and I won’t go into details. This article is mainly about getting started, mainly for the purpose of paving the way for later optimization)

Intercept JS dialog alert(), confirm(), prompt() messages through WebChromeClient's onJsAlert(), onJsConfirm(), onJsPrompt() method callbacks

Specific principle: Android intercepts JS dialog boxes (that is, the above three methods) through the onJsAlert(), onJsConfirm(), and onJsPrompt() method callbacks of WebChromeClient, obtains their message content, and then parses them.
The first step: still prepare an HTML, of course it can be the issued HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>SoarYuan</title>
    <script>
        function clickprompt(){
    
    
            // 调用prompt()
            var result=prompt("js://webview?arg1=哈哈哈&arg2=呜呜呜");
            alert("demo " + result);
        }
      
    </script>
</head>

<!-- 点击按钮则调用clickprompt()  -->
<body>
<button type="button" id="button3" onclick="clickprompt()">点击调用,Android方法</button>
</body>
</html>

Step 2: Override onJsPrompt() on Android through WebChromeClient.
When the above JS code is loaded using mWebView.loadUrl("file:///android_asset/javascript.html"), the callback onJsPrompt() will be triggered

webView.setWebChromeClient(new WebChromeClient(){
    
    
    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    
    
        // 注意一般是通过message去传送内容,message是代表promt())的内容。
        Uri uri = Uri.parse(message);
        Set<String> collection = uri.getQueryParameterNames();
        new Handler(Looper.getMainLooper()).post(() ->{
    
    
            Toast.makeText(aaa.this, "哈哈哈" + collection.size(), Toast.LENGTH_SHORT).show();
        });
        return super.onJsPrompt(view, url, message, defaultValue, result);
    }
});

Android calls JS

  1. Through WebView's loadUrl()
  2. via WebView's evaluateJavascript()

Suggestion: Use the two in combination, because WebView's evaluateJavascript() is supported after Android4.4, and it needs to be adapted to WebView's loadUrl() before and used together.

Through WebView's loadUrl()

Step 1: Prepare an HTML, of course it can be the issued HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>WebView</title>
    <!--Android需要调用的方法-->
    <script>
         function callJS(){
    
    
             alert("Android调用了JS的callJS方法");
         }
    </script>
</head>
</html>

Step 2: Call the JS code through the WebView setting in Android.
The JS code call must be called after the onPageFinished() callback
, otherwise an error will be reported: Uncaught ReferenceError: functionName is not defined

WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true); // 设置与Js交互的权限
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);// 设置允许JS弹窗

webView.loadUrl("file:///android_asset/js4.html");
button = (Button) findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
    
    
    @Override
    public void onClick(View v) {
    
    
        // 通过Handler发送消息
        webView.post(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                // 调用javascript的callJS()方法
                webView.loadUrl("javascript:callJS()");
            }
        });
    }
});

// webview只是载体,内容的渲染需要使用webviewChromClient类去实现
// 通过设置WebChromeClient对象处理JavaScript的对话框
webView.setWebChromeClient(new WebChromeClient());

After passing WebView.evaluateJavascript()-Android4.4

  • It is more efficient and simpler to use than the first method. Only available after Android 4.4
  • The execution of this method will not refresh the page, while the execution of the first method (loadUrl) will.
webView.evaluateJavascript("javascript:callJS()", new ValueCallback<String>() {
    
    
    @Override 
    public void onReceiveValue(String value) {
    
     
        //此处为 js 返回的结果     
    }
}); 

Normal use requires a mix of

Because it is compatible with versions prior to Android 4.4

if (Build.VERSION.SDK_INT < 18) {
    
    
    webView.loadUrl("javascript:callJS()");
} else {
    
    
    webView.evaluateJavascript("javascript:callJS()", new ValueCallback<String>() {
    
    
        @Override
        public void onReceiveValue(String value) {
    
    
            //此处为 js 返回的结果
        }
    });
}

JS call with callback

The method of two-way communication between APP and Web has been mentioned above.
But from one end, it is still a one-way communication process. For example, from the perspective of the Web: the Web calls the method of the APP, and the APP is directly related to the operation but cannot return the result to the Web. However, in actual use, the result of the operation is often required. Return, which is the JS callback.

Realization principle

In fact, this kind of callback is realized based on the previous one-way communication.
When calling at one end, we add a callbackId tag to the parameter corresponding to the callback. After receiving the call request, the other end performs actual operations. Make another call, return the result and callbackId back, this end matches the corresponding callback according to the callbackId, and just pass the result into execution.
In other words, in fact, the call of the callback is realized by two single communications.

the whole example

Web side code

<body>
  <div>
    <button id="showBtn">获取APP输入,以Web弹窗展现</button>
  </div>
</body>
<script>
  let id = 1;
  // 根据id保存callback
  const callbackMap = {
    
    };
  // 使用JSSDK封装调用与APP通信的事件,避免过多的污染全局环境
  window.JSSDK = {
    
    
    // 获取APP端输入框value,带有回调
    getNativeEditTextValue(callback) {
    
    
      const callbackId = id++;
      callbackMap[callbackId] = callback;
      // 调用JSB方法,并将callbackId传入
      window.NativeBridge.getNativeEditTextValue(callbackId);
    },
    // 接收APP端传来的callbackId
    receiveMessage(callbackId, value) {
    
    
      if (callbackMap[callbackId]) {
    
    
        // 根据ID匹配callback,并执行
        callbackMap[callbackId](value);
      }
    }
  };

        const showBtn = document.querySelector('#showBtn');
  // 绑定按钮事件
  showBtn.addEventListener('click', e => {
    
    
    // 通过JSSDK调用,将回调函数传入
    window.JSSDK.getNativeEditTextValue(value => window.alert('APP输入值:' + value));
  });
</script>

Android code

webView.addJavascriptInterface(new NativeBridge(this), "NativeBridge");

class NativeBridge {
    
    
  private Context ctx;
  NativeBridge(Context ctx) {
    
    
    this.ctx = ctx;
  }

  // 获取APP端输入值
  @JavascriptInterface
  public void getNativeEditTextValue(int callbackId) {
    
    
    MainActivity mainActivity = (MainActivity)ctx;
    // 获取APP端输入框的value
    String value = mainActivity.editText.getText().toString();
    // 需要注入在Web执行的JS代码
    String jsCode = String.format("window.JSSDK.receiveMessage(%s, '%s')", callbackId, value);
    // 在UI线程中执行
    mainActivity.runOnUiThread(new Runnable() {
    
    
      @Override
      public void run() {
    
    
        mainActivity.webView.evaluateJavascript(jsCode, null);
      }
    });
  }
}

Clicking the button on the Web side will get the value of the input box on the APP side, and display the value in a pop-up window on the Web side, thus realizing Web->APP JSB calls with callbacks, and the same logic as APP->Web , the difference is that the callback is saved on the APP side.

problems in development

shouldInterceptRequest 和 shouldOverrideUrlLoading

shouldInterceptRequest and shouldOverrideUrlLoading are methods of WebView, which are used to intercept network requests and page jumps in WebView.

  1. shouldInterceptRequest:

    • Function: Intercept network requests initiated by WebView.
    • Method signature: WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request)
    • Parameter Description:
      • view: the current WebView object.
      • request: represents the WebResourceRequest object of the current request, including the requested URL, request header, request method and other information.
    • Return value: WebResourceResponse object, used to return the intercepted response data to WebView. Custom data can be passed in through the WebResourceResponse constructor.
    • Scenario: It can be used to implement caching mechanism, modify request header, block advertisements and other functions.
  2. shouldOverrideUrlLoading:

    • Function: Intercept the page jump request in WebView.
    • Method signature: boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request)
    • Parameter Description:
      • view: the current WebView object.
      • request: represents the WebResourceRequest object of the current request, including the requested URL, request header, request method and other information.
    • Return value: boolean, returning true means intercepting the request and not loading the web page; returning false means continuing to load the web page.
    • Scenario: It can be used to intercept specific URL requests, perform redirection or other business logic processing, such as processing third-party logins, processing deep link jumps, etc.

Summary:
shouldInterceptRequest is used to intercept the network request initiated by WebView and process the response result, while shouldOverrideUrlLoading is used to intercept the page jump request in WebView and process accordingly. The two are generally used together. By intercepting network requests and page jumps, more flexible WebView applications can be realized, such as customized caching, blocking advertisements, and processing specific URLs.

The problem of WebView memory leak

Can be divided into three points to solve:

  • Not initialized in XML, directly new in the code, added to the layout
    • When a WebView is defined in the xml layout file, the WebView will be added to the view hierarchy and held as a view object. When the Activity or Fragment is destroyed, if the WebView object is not released in time, it will cause a memory leak of the WebView.
    • In contrast, by instantiating WebView and adding it to the view hierarchy through code, you can more flexibly control the life cycle of WebView. When the Activity or Fragment is destroyed, call the destroy() method of WebView in the corresponding life cycle method (such as onDestroy) to release the WebView object, thereby avoiding the memory leak of WebView.
  • Don't let WebView hold a Context reference to Activity/Fragment
    • The main reason for the WebView memory leak is that the Context of the Activity/Fragment is referenced, and the design problem of the WebView itself prevents the Activity/Fragment from being released immediately. Since the WebView cannot release the Context immediately, the global Context is used to solve this problem.
// 让 WebView 使用 ApplicationContext
WebView webview = new WebView(this.applicationContext)
  • When destroyed, stop loading the WebView and remove it from the parent control
@Override
protected void onDestroy() {
    
    
    if (webView != null) {
    
    
        webView.removeAllViews();
        ((ViewGroup) webView.getParent()).removeView(webView);
        webView.setTag(null);
        webView.clearHistory();
        webView.destroy();
        webView = null;
    }
}

Supplement 1: In fact, the new version of webView (on Android6.0) and above, the problem of webView leakage has been officially solved, but it is recommended to add these solutions to avoid unnecessary problems.
Supplement 2: If you use a separate process, there is no such problem, just kill the process and you are done.

Alert cannot pop up

WebChromeClient is not set, set it according to the following code:

  • The webview is just a carrier, and the rendering of the content needs to be realized by using the webviewChromClient class
  • Handle JavaScript dialogs by setting the WebChromeClient object
webView.setWebChromeClient(new WebChromeClient());

Uncaught ReferenceError: functionName is not defined

Reason: The page has not been loaded, and the JS method cannot be found.
After the call waits for the onPageFinished() callback

@Override
public void onPageFinished(WebView view, String url) {
    
    
     // 页面加载结束,可以去关闭进度条等加载动画
}

Uncaught TypeError: Object [object Object] has no method

  • No @JavascriptInterface annotation added
  • Code obfuscation problem: add the mapped JS calling class in the obfuscation file
在proguard-rules.pro中添加混淆。
-keepattributes *Annotation*
        -keepattributes *JavascriptInterface*
        -keep public class xx.xxx.JsMethod{
    
    
    public <methods>;
}
其中xx.xxx..JsMethod 是不需要混淆的类

Summarize

Awesome! ! ! !

Guess you like

Origin blog.csdn.net/weixin_45112340/article/details/132510207