如何设计一个优雅健壮的Android WebView?(下)

  1. 注入的进度阈值可以自由定制,理论上10%-100%都是合理的,我们使用了80%。

H5页面、Weex页面与Native页面交互——KaolaRouter

H5页面、Weex页面与Native页面的交互是通过URL拦截实现的。在WebView中,WebViewClient.shouldOverrideUrlLoading()方法能够获取到当前加载的URL,然后把URL传递给考拉路由框架,便可以判断URL是否能够跳转到其他非H5页面,考拉路由框架在《考拉Android客户端路由总线设计》一文中有详细介绍,但当时未引入Weex页面,关于如何整合三者的通信,后续文章会有详细介绍。

WebViewClient.shouldOverrideUrlLoading()中,根据URL类型做了判断:

public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (StringUtils.isNotBlank(url) && url.equals(“about:blank”)) { //js调用reload刷新页面时候,个别机型跳到空页面问题修复
url = getUrl();
}
url = WebViewUtils.removeBlank(url);
mCallProgressCallback = true;
//允许启动第三方应用客户端
if (WebViewUtils.canHandleUrl(url)) {
boolean handleByCaller = false;
// 如果不是用户触发的操作,就没有必要交给上层处理了,直接走url拦截规则。
if (null != mIWebViewClient && isTouchByUser()) {
// 先交给业务层拦截处理
handleByCaller = mIWebViewClient.shouldOverrideUrlLoading(view, url);
}
if (!handleByCaller) {
// 业务层不拦截,走通用路由总线规则
handleByCaller = handleOverrideUrl(url);
}
mRedirectProtected = true;
return handleByCaller || super.shouldOverrideUrlLoading(view, url);
} else {
try {
notifyBeforeLoadUrl(url);
// https://sumile.cn/archives/1223.html
Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setComponent(null);
intent.setSelector(null);
mContext.startActivity(intent);
if (!mIsBlankPageRedirect) {
back();
}
} catch (Exception e) {
ExceptionUtils.printExceptionTrace(e);
}
return true;
}
}

private boolean handleOverrideUrl(final String url) {
RouterResult result = WebActivityRouter.startFromWeb(
new IntentBuilder(mContext, url).setRouterActivityResult(new RouterActivityResult() {
@Override
public void onActivityFound() {
if (!mIsBlankPageRedirect) {
// 路由拦截成功以后,为防止首次进入WebView产生白屏,因此加了保护机制
back();
}
}

@Override
public void onActivityNotFound() {

}
}));
return result.isSuccess();
}

代码里写了注释,就不一一解释了。

WebView下拉刷新实现

由于考拉使用的下拉刷新跟Material Design所使用的下拉刷新样式不一致,因此不能直接套用SwipeRefreshLayout。考拉使用的是一套改造过的Android-PullToRefresh,WebView的下拉刷新,正是继承自PullToRefreshBase来实现的。

/**

  • 创建者:Square Xu
  • 日期:2017/2/23
  • 功能模块:webview下拉刷新组件
    */
    public class PullToRefreshWebView extends PullToRefreshBase {
    public PullToRefreshWebView(Context context) {
    super(context);
    }

public PullToRefreshWebView(Context context, AttributeSet attrs) {
super(context, attrs);
}

public PullToRefreshWebView(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs);
}

public PullToRefreshWebView(Context context, Mode mode) {
super(context, mode);
}

public PullToRefreshWebView(Context context, Mode mode, AnimationStyle animStyle) {
super(context, mode, animStyle);
}

@Override
public Orientation getPullToRefreshScrollDirection() {
return Orientation.VERTICAL;
}

@Override
protected KaolaWebview createRefreshableView(Context context, AttributeSet attrs) {
KaolaWebview kaolaWebview = new KaolaWebview(context, attrs);
//解决键盘弹起时候闪动的问题
setGravity(AXIS_PULL_BEFORE);
return kaolaWebview;
}

@Override
protected boolean isReadyForPullEnd() {
return false;
}

@Override
protected boolean isReadyForPullStart() {
return getRefreshableView().getScrollY() == 0;
}
}

考拉使用了全屏模式实现沉浸式状态栏及滑动返回,全屏模式和WebView下拉刷新相结合对键盘的弹起产生了闪动效果,经过组内大神的研究与多次调试(感谢@俊俊),发现setGravity(AXIS_PULL_BEFORE)能够解决闪动的问题。

如何处理加载错误(Http、SSL、Resource)?

对于WebView加载一个网页过程中所产生的错误回调,大致有三种:

  • WebViewClient.onReceivedHttpError(webView, webResourceRequest, webResourceResponse)

任何HTTP请求产生的错误都会回调这个方法,包括主页面的html文档请求,iframe、图片等资源请求。在这个回调中,由于混杂了很多请求,不适合用来展示加载错误的页面,而适合做监控报警。当某个URL,或者某个资源收到大量报警时,说明页面或资源可能存在问题,这时候可以让相关运营及时响应修改。

  • WebViewClient.onReceivedSslError(webview, sslErrorHandler, sslError)

任何HTTPS请求,遇到SSL错误时都会回调这个方法。比较正确的做法是让用户选择是否信任这个网站,这时候可以弹出信任选择框供用户选择(大部分正规浏览器是这么做的)。但人都是有私心的,何况是遇到自家的网站时。我们可以让一些特定的网站,不管其证书是否存在问题,都让用户信任它。在这一点上,分享一个小坑。考拉的SSL证书使用的是GeoTrust的GeoTrust SSL CA - G3,但是在某些机型上,打开考拉的页面都会提示证书错误。这时候就不得不使用“绝招”——让考拉的所有二级域都是可信任的。

@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
if (UrlUtils.isKaolaHost(getUrl())) {
handler.proceed();
} else {
super.onReceivedSslError(view, handler, error);
}
}

  • WebViewClient.onReceivedError(webView, webResourceRequest, webResourceError)

只有在主页面加载出现错误时,才会回调这个方法。这正是展示加载错误页面最合适的方法。然鹅,如果不管三七二十一直接展示错误页面的话,那很有可能会误判,给用户造成经常加载页面失败的错觉。由于不同的WebView实现可能不一样,所以我们首先需要排除几种误判的例子:

  1. 加载失败的url跟WebView里的url不是同一个url,排除;
  2. errorCode=-1,表明是ERROR_UNKNOWN的错误,为了保证不误判,排除
  3. failingUrl=null&errorCode=-12,由于错误的url是空而不是ERROR_BAD_URL,排除

@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
super.onReceivedError(view, errorCode, description, failingUrl);

// -12 == EventHandle.ERROR_BAD_URL, a hide return code inside android.net.http package
if ((failingUrl != null && !failingUrl.equals(view.getUrl()) && !failingUrl.equals(view.getOriginalUrl())) /* not subresource error*/
|| (failingUrl == null && errorCode != -12) /not bad url/
|| errorCode == -1) { //当 errorCode = -1 且错误信息为 net::ERR_CACHE_MISS
return;
}

if (!TextUtils.isEmpty(failingUrl)) {
if (failingUrl.equals(view.getUrl())) {
if (null != mIWebViewClient) {
mIWebViewClient.onReceivedError(view);
}
}
}
}

如何操作cookie?

Cookie默认情况下是不需要做处理的,如果有特殊需求,如针对某个页面设置额外的Cookie字段,可以通过代码来控制。下面列出几个有用的接口:

  • 获取某个url下的所有Cookie:CookieManager.getInstance().getCookie(url)
  • 判断WebView是否接受Cookie:CookieManager.getInstance().acceptCookie()
  • 清除Session Cookie:CookieManager.getInstance().removeSessionCookies(ValueCallback<Boolean> callback)
  • 清除所有Cookie:CookieManager.getInstance().removeAllCookies(ValueCallback<Boolean> callback)
  • Cookie持久化:CookieManager.getInstance().flush()
  • 针对某个主机设置Cookie:CookieManager.getInstance().setCookie(String url, String value)

如何调试WebView加载的页面?

在Android 4.4版本以后,可以使用Chrome开发者工具调试WebView内容^5。调试需要在代码里设置打开调试开关。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
WebView.setWebContentsDebuggingEnabled(true);
}

开启后,使用USB连接电脑,加载URL时,打开Chrome开发者工具,在浏览器输入

chrome://inspect

可以看到当前正在浏览的页面,点击inspect即可看到WebView加载的内容。

WebView优化

除了上面提到的基本操作用来实现一个完整的浏览器功能外,WebView的加载速度、稳定性和安全性是可以进一步加强和提高的。下面从几个方面介绍一下WebView的优化方案,这些方案可能并不是都适用于所有场景,但思路是可以借鉴的。

CandyWebCache

我们知道,在加载页面的过程中,js、css和图片资源占用了大量的流量,如果这些资源一开始就放在本地,或者只需要下载一次,后面重复利用,岂不美哉。尽管WebView也有几套缓存方案^6,但是总体而言效果不理想。基于自建缓存系统的思路,由网易杭研研发的CandyWebCache项目应运而生。CandyWebCache是一套支持离线缓存WebView资源并实时更新远程资源的解决方案,支持打母包时下载当前最新的资源文件集成到apk中,也支持在线实时更新资源。在WebView中,我们需要拦截WebViewClient.shouldInterceptRequest()方法,检测缓存是否存在,存在则直接取本地缓存数据,减少网络请求产生的流量。

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
if (WebSwitchManager.isWebCacheEnabled()) {
try {
WebResourceResponse resourceResponse = CandyWebCache.getsInstance().getResponse(view, request);
return WebViewUtils.handleResponseHeader(resourceResponse);
} catch (Throwable e) {
ExceptionUtils.uploadCatchedException(e);
}
}
return super.shouldInterceptRequest(view, request);
}

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
if (WebSwitchManager.isWebCacheEnabled()) {
try {
WebResourceResponse resourceResponse = CandyWebCache.getsInstance().getResponse(view, url);
return WebViewUtils.handleResponseHeader(resourceResponse);
} catch (Throwable e) {
ExceptionUtils.uploadCatchedException(e);
}
}
return super.shouldInterceptRequest(view, url);
}

除了上述缓存方案外,腾讯的QQ会员团队也推出了开源的解决方案VasSonic,旨在提升H5的页面访问体验,但最好由前后端一起配合改造。这套整体的解决方案有很多借鉴意义,考拉也在学习中。

Https、HttpDns、CDN

将http请求切换为https请求,可以降低运营商网络劫持(js劫持、图片劫持等)的概率,特别是使用了http2后,能够大幅提升web性能,减少网络延迟,减少请求的流量。

HttpDns,使用http协议向特定的DNS服务器进行域名解析请求,代替基于DNS协议向运营商的Local DNS发起解析请求,可以降低运营商DNS劫持带来的访问失败。目前在WebView上使用HttpDns尚存在一定问题,网上也没有较好的解决方案(阿里云Android WebView+HttpDns最佳实践腾讯云HttpDns SDK接入webview接入HttpDNS实践),因此还在调研中。

另一方面,可以把静态资源部署到多路CDN,直接通过CDN地址访问,减少网络延迟,多路CDN保障单个CDN大面积节点访问失败时可切换到备用的CDN上。

WebView独立进程

WebView实例在Android7.0系统以后,已经可以选择运行在一个独立进程上^7;8.0以后默认就是运行在独立的沙盒进程中^8,未来Google也在朝这个方向发展,具体的WebView历史可以参考上一篇文章《如何设计一个优雅健壮的Android WebView?(上)》第一小节。

Android7.0系统以后,WebView相对来说是比较稳定的,无论承载WebView的容器是否在主进程,都不需要担心WebView崩溃导致应用也跟着崩溃。然后7.0以下的系统就没有这么幸运了,特别是低版本的WebView。考虑应用的稳定性,我们可以把7.0以下系统的WebView使用一个独立进程的Activity来包装,这样即使WebView崩溃了,也只是WebView所在的进程发生了崩溃,主进程还是不受影响的。

public static Intent getWebViewIntent(Context context) {
Intent intent;
if (isWebInMainProcess()) {
intent = new Intent(context, MainWebviewActivity.class);
} else {
intent = new Intent(context, WebviewActivity.class);
}
return intent;
}

public static boolean isWebInMainProcess() {
return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N;
}

WebView免流

从去年开始,市场上出现了一批互联网套餐卡,如腾讯王卡、蚂蚁宝卡、京东强卡、阿里鱼卡、网易白金卡等,这些互联网套餐相比传统的运营商套餐来说,资费便宜,流量多,甚至某些卡还拥有特殊权限——对某些应用免流。如网易白金卡,对于网易系与百度系的部分应用实现免流。

免流原理

市面上常见的免流应用,原理无非就是走“特殊通道”,让这一部分的流量不计入运营商的流量统计平台中。Android中要实现这种“特殊通道”,有几种方案。

  1. 微屁恩。目前运营商貌似没有采用这种方案,但确实是可行的。由于国情,不多介绍,懂的自然懂。
  2. 全局代理。把所有的流量中转到代理服务器中,代理服务器再根据流量判断是否属于免流流量。
  3. IP直连。走这个IP的所有流量,服务器判断是否免流。

WebView免流方案

对于上面提到的几种方案,native页面所有的请求都是应用层发起的,实际上都比较好实现,但WebView的页面和资源请求是通过JNI发起的,想要拦截请求的话,需要一些功夫。网罗网上的所有方案,目前觉得可行的有两种,分别是全局代理和拦截WebViewClient.shouldInterceptRequest()

全局代理

由于WebView并没有提供接口针对具体的WebView实例设置代理,所以我们只能进行全局代理。设置全局代理时,需要通知系统代理环境发生了改变,不幸地是,Android并没有提供公开的接口,这就导致了我们只能hook系统接口,根据不同的系统版本来实现通知的目的^9^10。6.0以后的系统,尚未尝试是否可行,根据公司同事的反馈,和5.0系统的方案是一致的。

/**

  • Set Proxy for Android 4.1 - 4.3.
    */
    @SuppressWarnings(“all”)
    private static boolean setProxyJB(WebView webview, String host, int port) {
    Log.d(LOG_TAG, “Setting proxy with 4.1 - 4.3 API.”);

try {
Class wvcClass = Class.forName(“android.webkit.WebViewClassic”);
Class wvParams[] = new Class[1];
wvParams[0] = Class.forName(“android.webkit.WebView”);
Method fromWebView = wvcClass.getDeclaredMethod(“fromWebView”, wvParams);
Object webViewClassic = fromWebView.invoke(null, webview);

Class wv = Class.forName(“android.webkit.WebViewClassic”);
Field mWebViewCoreField = wv.getDeclaredField(“mWebViewCore”);
Object mWebViewCoreFieldInstance = getFieldValueSafely(mWebViewCoreField, webViewClassic);

Class wvc = Class.forName(“android.webkit.WebViewCore”);
Field mBrowserFrameField = wvc.getDeclaredField(“mBrowserFrame”);
Object mBrowserFrame = getFieldValueSafely(mBrowserFrameField, mWebViewCoreFieldInstance);

Class bf = Class.forName(“android.webkit.BrowserFrame”);
Field sJavaBridgeField = bf.getDeclaredField(“sJavaBridge”);
Object sJavaBridge = getFieldValueSafely(sJavaBridgeField, mBrowserFrame);

Class ppclass = Class.forName(“android.net.ProxyProperties”);
Class pparams[] = new Class[3];
pparams[0] = String.class;
pparams[1] = int.class;
pparams[2] = String.class;
Constructor ppcont = ppclass.getConstructor(pparams);

Class jwcjb = Class.forName(“android.webkit.JWebCoreJavaBridge”);
Class params[] = new Class[1];
params[0] = Class.forName(“android.net.ProxyProperties”);
Method updateProxyInstance = jwcjb.getDeclaredMethod(“updateProxy”, params);

updateProxyInstance.invoke(sJavaBridge, ppcont.newInstance(host, port, null));
} catch (Exception ex) {
Log.e(LOG_TAG, "Setting proxy with >= 4.1 API failed with error: " + ex.getMessage());
return false;
}

Log.d(LOG_TAG, “Setting proxy with 4.1 - 4.3 API successful!”);
return true;
}

/**

  • Set Proxy for Android 5.0.
    */
    public static void setWebViewProxyL(Context context, String host, int port) {
    System.setProperty(“http.proxyHost”, host);
    System.setProperty(“http.proxyPort”, port + “”);
    try {
    Context appContext = context.getApplicationContext();
    Class applictionClass = Class.forName(“android.app.Application”);
    Field mLoadedApkField = applictionClass.getDeclaredField(“mLoadedApk”);
    mLoadedApkField.setAccessible(true);
    Object mloadedApk = mLoadedApkField.get(appContext);
    Class loadedApkClass = Class.forName(“android.app.LoadedApk”);
    Field mReceiversField = loadedApkClass.getDeclaredField(“mReceivers”);
    mReceiversField.setAccessible(true);
    ArrayMap receivers = (ArrayMap) mReceiversField.get(mloadedApk);
    for (Object receiverMap : receivers.values()) {
    for (Object receiver : ((ArrayMap) receiverMap).keySet()) {
    Class clazz = receiver.getClass();
    if (clazz.getName().contains(“ProxyChangeListener”)) {
    Method onReceiveMethod = clazz.getDeclaredMethod(“onReceive”, Context.class, Intent.class);
    Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
    onReceiveMethod.invoke(receiver, appContext, intent);
    }
    }
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

需要注意的是,在WebView退出时,需要重置代理。

拦截WebViewClient.shouldInterceptRequest()

拦截WebViewClient.shouldInterceptRequest()的目的是使用免流的第三种方案——IP替换。直接看代码。

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
WebResourceResponse resourceResponse = CandyWebCache.getsInstance().getResponse(view, request);
if (request.getUrl() != null && request.getMethod().equalsIgnoreCase(“get”)) {
Uri uri = request.getUrl();
String url = uri.toString();
String scheme = uri.getScheme().trim();
String host = uri.getHost();
String path = uri.getPath();
if (TextUtils.isEmpty(path) || TextUtils.isEmpty(host)) {
return null;
}
// HttpDns解析css文件的网络请求及图片请求
if ((scheme.equalsIgnoreCase(“http”) || scheme.equalsIgnoreCase(“https”)) && (path.endsWith(“.css”)
|| path.endsWith(“.png”)
|| path.endsWith(“.jpg”)
|| path.endsWith(“.gif”)
|| path.endsWith(“.js”))) {
try {
URL oldUrl = new URL(uri.toString());
URLConnection connection;
// 获取HttpDns域名解析结果
List ips = HttpDnsManager.getInstance().getIPListByHostAsync(host);
if (!ListUtils.isEmpty(ips)) {
String ip = ips.get(0);
String newUrl = url.replaceFirst(oldUrl.getHost(), ip);
connection = new URL(newUrl).openConnection(); // 设置HTTP请求头Host域
connection.setRequestProperty(“Host”, oldUrl.getHost());
} else {
connection = new URL(url).openConnection(); // 设置HTTP请求头Host域
}
String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension);
return new WebResourceResponse(mimeType, “UTF-8”, connection.getInputStream());
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return super.shouldInterceptRequest(view, request);
}

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
if (!TextUtils.isEmpty(url) && Uri.parse(url).getScheme() != null) {
Uri uri = Uri.parse(url);
String scheme = uri.getScheme().trim();
String host = uri.getHost();
String path = uri.getPath();
if (TextUtils.isEmpty(path) || TextUtils.isEmpty(host)) {
return null;
}
// HttpDns解析css文件的网络请求及图片请求
if ((scheme.equalsIgnoreCase(“http”) || scheme.equalsIgnoreCase(“https”)) && (path.endsWith(“.css”)
|| path.endsWith(“.png”)
|| path.endsWith(“.jpg”)
|| path.endsWith(“.gif”)
|| path.endsWith(“.js”))) {
try {
URL oldUrl = new URL(uri.toString());
URLConnection connection;
// 获取HttpDns域名解析结果
List ips = HttpDnsManager.getInstance().getIPListByHostAsync(host);
if (!ListUtils.isEmpty(ips)) {
String ip = ips.get(0);
String newUrl = url.replaceFirst(oldUrl.getHost(), ip);
connection = new URL(newUrl).openConnection(); // 设置HTTP请求头Host域
connection.setRequestProperty(“Host”, oldUrl.getHost());
} else {
connection = new URL(url).openConnection(); // 设置HTTP请求头Host域
}
String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension);
return new WebResourceResponse(mimeType, “UTF-8”, connection.getInputStream());
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return super.shouldInterceptRequest(view, url);
}

使用此种方案,还可以把WebView网络请求与native网络请求使用的框架统一起来,方便管理。

总结

本文介绍了WebView在开发中的一些实践经验和优化流程。为了满足业务需求,WebView着实提供了非常丰富的接口供应用层处理业务逻辑。针对WebView的二次开发,本文介绍了一些常用的回调处理逻辑以及开发过程中总结下的经验。由于是经验,不一定是准确的,若有错误的地方,敬请指出纠正,不胜感激!


参考链接

  1. medium.com/@filipe.bat…
  2. stackoverflow.com/questions/2…
  3. stackoverflow.com/questions/2…
  4. developer.android.com/about/versi…
  5. developers.google.com/web/tools/c…
  6. www.jianshu.com/p/5e7075f48…
  7. developer.android.com/about/versi…
  8. developer.android.com/about/versi…
  9. stackoverflow.com/questions/2…
  10. stackoverflow.com/questions/4…

尾声

以薪资待遇为基础,以发展为最终目标,要在高薪资的地方,谋求最好的发展!

下面是有几位Android行业大佬对应上方技术点整理的一些进阶资料。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

si…]( )
8. developer.android.com/about/versi…
9. stackoverflow.com/questions/2…
10. stackoverflow.com/questions/4…

尾声

以薪资待遇为基础,以发展为最终目标,要在高薪资的地方,谋求最好的发展!

下面是有几位Android行业大佬对应上方技术点整理的一些进阶资料。

[外链图片转存中…(img-1EOGaZWm-1714600818945)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 21
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值