Hybrid App目测大家都不陌生了, 详情介绍参考 http://blog.csdn.net/u012014301/article/details/55251519
本文主要介绍Android WebView离线资源加载的方案
为了使打开H5页面更快速, 通常会把H5的文件和资源打包做离线缓存, 这里主要介绍两种方式缓存加载的方式,以及两种方式的优缺点对比
H5文件资源打包和解压这里不做过多介绍, 通常涉及一个压缩包的下载,解压,版本升级等问题,不是本文的重点,本文假定已经资源文件以及离线缓存在了App的Cache目录下
首先介绍两个核心相关的方法
shouldOverrideUrlLoading
/**
* Give the host application a chance to take over the control when a new
* url is about to be loaded in the current WebView. If WebViewClient is not
* provided, by default WebView will ask Activity Manager to choose the
* proper handler for the url. If WebViewClient is provided, return true
* means the host application handles the url, while return false means the
* current WebView handles the url.
* This method is not called for requests using the POST "method".
*
* @param view The WebView that is initiating the callback.
* @param url The url to be loaded.
* @return True if the host application wants to leave the current WebView
* and handle the url itself, otherwise return false.
* @deprecated Use {@link #shouldOverrideUrlLoading(WebView, WebResourceRequest)
* shouldOverrideUrlLoading(WebView, WebResourceRequest)} instead.
*/
@Deprecated
public boolean shouldOverrideUrlLoading(WebView view, String url) {
return false;
}
从注释上可以看出, 上面这个方法能够让应用决定是否重载对一个url加载,如果该方法返回true, 则表示应用处理了这个Url,返回false则由WebView处理url, WebView处理实际上就是打开该url, 该方法只会在触发页面内跳转的时候(href=’xxx’)的时候才会调用, loadUrl的时候并不会调用该方法
shouldInterceptRequest
/**
* Notify the host application of a resource request and allow the
* application to return the data. If the return value is null, the WebView
* will continue to load the resource as usual. Otherwise, the return
* response and data will be used. NOTE: This method is called on a thread
* other than the UI thread so clients should exercise caution
* when accessing private data or the view system.
*
* @param view The {@link android.webkit.WebView} that is requesting the
* resource.
* @param url The raw url of the resource.
* @return A {@link android.webkit.WebResourceResponse} containing the
* response information or null if the WebView should load the
* resource itself.
* @deprecated Use {@link #shouldInterceptRequest(WebView, WebResourceRequest)
* shouldInterceptRequest(WebView, WebResourceRequest)} instead.
*/
@Deprecated
public WebResourceResponse shouldInterceptRequest(WebView view,
String url) {
return null;
}
从这个方法的名字可以看出, 它具有拦截的功能, 类似于OkHttp的网络拦截器,对于一个请求, 应用可以主动拦截这个请求的返回, 如果该返回null, 则表示交由WebView处理, 应用不做拦截
一 File本地文件传输协议加载
要使用File协议,基本的格式如下:file:///文件路径
通常File本地文件与WebViewClient的shouldOverrideUrlLoading结合使用, 可以实现简单的离线文件加载效果
当我们请求一个H5页面的时候,如果该H5文件以及在本地缓存中存在,我们可以使用用file文件加载协议加载该页面,同时让shouldOverrideUrlLoading方法返回true则可以实现我们的离线文件加载,下面用代码演示该方案.
public class MainActivity extends Activity {
protected WebView mWebView;
private static final String sURL = "http://www.dy2018.com/i/97824.html";
private long mStarTime;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView = (WebView) findViewById(R.id.web_view);
initWebView();
mStarTime = System.currentTimeMillis();
openUrl(sURL);
}
private void initWebView() {
WebSettings settings = mWebView.getSettings();
settings.setJavaScriptEnabled(true);
mWebView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
Log.d("LJTAG", "shouldOverrideUrlLoading: " + url);
return MainActivity.this.shouldOverrideUrlLoading(url);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
Log.d("LJTAG", "useTime: " + (System.currentTimeMillis() - mStarTime));
}
});
}
/**
* shouldOverrideUrlLoading 是在页面内跳转的时候会被调用的方法
* 第一次loadUrl的时候 是不会调用shouldOverrideUrlLoading方法的
* 所以打开指定页面的时候要通过我们自己的方法去打开指定页面
*/
private void openUrl(String url) {
String cacheFileUrl = getCacheFileUrl(url);
if (null != cacheFileUrl) {
mWebView.loadUrl(cacheFileUrl);
} else {
mWebView.loadUrl(url);
}
}
/**
* 根据url判断本地缓存文件是否存在
* 如果存在 则应用拦截本次url加载同时使用file协议访问该文件
*
* @param url
* @return
*/
private boolean shouldOverrideUrlLoading(String url) {
String cacheFileUrl = getCacheFileUrl(url);
if (cacheFileUrl == null) return false;
mWebView.loadUrl(cacheFileUrl);
return true;
}
/**
* 如果Cache文件命中, 返回Cache文件的file协议地址,否则返回null
*
* @param aimUrl
* @return
*/
private String getCacheFileUrl(String aimUrl) {
try {
URL url = new URL(aimUrl);
Log.d("LJTAG", "getCacheFileUrl: " + url);
if (url.getProtocol().startsWith("http")) {
// 如果本地已缓存该文件 则返回该文件的file协议地址
// 这里只是简单的以本地SD卡的应用缓存文件夹 作为url根目录拼接起来
// 比如说对于请求 http://www.dy2018.com/i/97729.html
// 在本地的路径就是 getExternalCacheDir() + "/i/97729.html"
// 真实环境中要考虑SD卡是否存在的问题以及具体的缓存目录选择
File cacheFile = new File(getExternalCacheDir(), url.getPath());
if (cacheFile.exists()) {
Log.d("LJTAG", cacheFile.getAbsolutePath() + " 文件存在, 直接使用file协议访问");
return "file://" + cacheFile.getAbsolutePath();
} else {
Log.d("LJTAG", cacheFile.getAbsolutePath() + " 文件不存在, 直接访问线上资源");
}
}
} catch (MalformedURLException e) {
}
return null;
}
}
布局文件如下, 很简单, 就一个WebView
<?xml version="1.0" encoding="utf-8"?>
<WebView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/web_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.liujian.hybridproject.MainActivity" />
这里简单的加载了电影天堂的一个页面http://www.dy2018.com/i/97824.html, 我们看看运行的效果
因为当前缓存路径中没有任何文件, 所以在加载的时候直接走了线上, 我们看一下日志的打印
D/LJTAG: getCacheFileUrl: http://www.dy2018.com/i/97824.html
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/i/97824.html 文件不存在, 直接访问线上资源
D/LJTAG: useTime: 1467
从日志可以看出 没有使用缓存, 从loadUrl 到 onPageFinished总耗时1467ms(时间不是绝对的,更很多因素有关,只做对比参考)
把该网页保存下来, 对标题做一些修改,用来区分线下资源和线上资源, 再Push到cache目录之后
adb push 97824.html /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/i
[100%] /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/i
我们再看看效果
D/LJTAG: getCacheFileUrl: http://www.dy2018.com/i/97824.html
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/i/97824.html 文件存在, 直接使用file协议访问
D/LJTAG: useTime: 281
可以看出已经成功的加载出了本地缓存的文件,但是可以发现图片资源和页面布局都乱掉了,我们查看一下这个页面的html代码
<link href="/css/dygod.css" rel="stylesheet" type="text/css" />
<script src="/js/search.js" language="javascript"></script>
这里的css和js文件路径, 都是以相对路径的形式加载的,当我们以file文件协议访问的时候,都是相对根路径做文件访问
比如说访问以下地址
file:///storage/emulated/0/Android/data/com.liujian.hybridproject/cache/i/97824.html
如果该HTML文件中引用了 /css/dygod.css文件,那么浏览器实际上会去访问
file:///css/dygod.css
这个文件当然不存在, 所以页面的css和js都会加载失败
这个问题的解决方式有两种
- 修改css和js的引用路径,使用绝对路径加载, 比如说 /css/dygod.css 修改成 http://www.dy2018.com/css/dygod.css, 或者把相对根目录改为相对当前目录, 比如说把/css/dygod.css修改成 css/dygod.css (这个方案对H5的改动较大,目录结构都需要调整)
- 通过base标签指定 href, 这个其实和上面的换成绝对路径的方法差不多, 不过这个方法一劳永逸,相当于把当前页面中的跟目录指定为 href 所对应的地址, 相对于根目录的路径都会被替换成 href + 相对路径来加载, 下面的代码,即使当前的文件链接是file://, 最终请求的dygod.css的路径依然是 http://www.dy2018.com/css/dygod.css
<base href="http://www.dy2018.com">
<link href="/css/dygod.css" rel="stylesheet" type="text/css" />
我们在本地的离线HTML文件中写入base之后,再看加载的效果
D/LJTAG: getCacheFileUrl: http://www.dy2018.com/i/97824.html
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/i/97824.html 文件存在, 直接使用file协议访问
D/LJTAG: useTime: 537
可以看出HTML依然是我们的App本地离线缓存文件,但是css已经正常的加载出来了,同时页面的加载时间明显得到了很大的提升
方案一的离线加载方式基本就是上面的模式,可以看出这个方案比较简单,相当于使用了一个本地的缓存路径来搭建了一个服务器,文件从本地离线缓存中直接获取,从而提高了页面加载的速度
但是该方案有如下几个问题
- 非可链接跳转的资源请求离线加载,因为shouldOverrideUrlLoading是在页面跳转的时候才调用的方法,所以css,js文件要加载,只能使用上面两种方案解决文件不存在的问题,对HTML的开发和文件结构有一定的要求
- JS存在跨域问题,因为与接口相关的请求肯定可能是走线上服务器的,但是当前的链接其实是file本地文件协议访问的,所以要解决JS跨域问题,通常是要服务端和前端配合,或者前端和客户端配合,把前端的所有接口请求通过JSBBridge调用,通过客户端访问接口再回传给前端
所以方案一其实是一个侵入性比较强的离线资源加载方案,对客户端和前端都有一些约束,据笔者所知,某度某米的Hybrid资源加载方案就是这样的
二 shouldInterceptRequest拦截请求
shouldInterceptRequest是WebViewClient的方法,之前已经提到过,它相当于一个拦截器,可以获取到WebView中的所有请求,我们先来看看代码
public class InterceptActivity extends Activity {
protected WebView mWebView;
private static final String URL = "http://www.dy2018.com/i/97824.html";
private long mStarTime;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView = (WebView) findViewById(R.id.web_view);
initWebView();
mStarTime = System.currentTimeMillis();
mWebView.loadUrl(URL);
}
private void initWebView() {
WebSettings settings = mWebView.getSettings();
settings.setJavaScriptEnabled(true);
mWebView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
Log.d("LJTAG", "shouldInterceptRequest: " + url);
return null;
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
// Log.d("LJTAG", "useTime: " + (System.currentTimeMillis() - mStarTime));
}
});
}
/**
* 如果Cache文件命中, 返回Cache文件的file协议地址,否则返回null
*
* @param aimUrl
* @return
*/
private String getCacheFileUrl(String aimUrl) {
try {
URL url = new URL(aimUrl);
Log.d("LJTAG", "getCacheFileUrl: " + url);
if (url.getProtocol().startsWith("http")) {
// 如果本地已缓存该文件 则返回该文件的file协议地址
// 这里只是简单的以本地SD卡的应用缓存文件夹 作为url根目录拼接起来
// 比如说对于请求 http://www.dy2018.com/i/97729.html
// 在本地的路径就是 getExternalCacheDir() + "/i/97729.html"
// 真实环境中要考虑SD卡是否存在的问题以及具体的缓存目录选择
File cacheFile = new File(getExternalCacheDir(), url.getPath());
if (cacheFile.exists()) {
Log.d("LJTAG", cacheFile.getAbsolutePath() + " 文件存在, 直接使用file协议访问");
return "file://" + cacheFile.getAbsolutePath();
} else {
Log.d("LJTAG", cacheFile.getAbsolutePath() + " 文件不存在, 直接访问线上资源");
}
}
} catch (MalformedURLException e) {
}
return null;
}
}
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/i/97824.html
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/css/dygod.css
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/css/searchpage.css
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/search.js
D/LJTAG: shouldInterceptRequest: http://tu.23juqing.com/d/file/html/gndy/dyzz/2017-03-15/01477a7ebe05a1aa4f5fd053a050454c.jpg
D/LJTAG: shouldInterceptRequest: http://pstatic.xunlei.com/js/webThunderDetect.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/base64.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/thunderForumLinker.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/tj.js
...
这里只是简单的请求了这样一个链接http://www.dy2018.com/i/97824.html,但是这个方法拦截到到了里面所有的请求,如果我们在请求的时候把请求结果替换成本地文件, 应该就可以实现我们想要的效果了,这里我们看看这个方法的详细参数
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
return null;
}
从上文可以知道, 如果在这个方法返回null, 表示不拦截, WebView会自己去执行原有的加载逻辑, 如果我们返回一个WebResourceResponse, 那么WebView会用这个对象当做请求的结果,从而起到一个拦截的效果, 我们看看WebResourceResponse的构造方法
public WebResourceResponse(String mimeType, String encoding, InputStream data) {
mMimeType = mimeType;
mEncoding = encoding;
setData(data);
}
这是其中的一个构造方法, 熟悉HTML的同学应该知道这三个参数的作用, 我们先写个简单的测试样例, 当我们加载上面的电影天堂的链接的时候, 构造一个本地HTML的WebResourceResponse返回过去, 看看效果如何
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<META http-equiv=Content-Type content="text/html; charset=utf-8">
</head>
<body style="background-color: red">
</body>
</html>
HTML代码如上, 很简单, 就是一个全红色的网页, 我们首先不把它Push到Cache目录中, Android代码如下
public class InterceptActivity extends Activity {
protected WebView mWebView;
private static final String URL = "http://www.dy2018.com/i/97824.html";
private long mStarTime;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView = (WebView) findViewById(R.id.web_view);
initWebView();
mStarTime = System.currentTimeMillis();
mWebView.loadUrl(URL);
}
private void initWebView() {
WebSettings settings = mWebView.getSettings();
settings.setJavaScriptEnabled(true);
mWebView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
Log.d("LJTAG", "shouldInterceptRequest: " + url);
if (URL.equals(url)) {
File file = new File(getExternalCacheDir(), "local.html");
if (file.exists()) {
try {
InputStream inputStream = new FileInputStream(file);
return new WebResourceResponse("text/html", "utf-8", inputStream);
} catch (FileNotFoundException e) {
Log.e("LJTAG", e.getMessage());
}
}
}
return null;
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
Log.d("LJTAG", "useTime: " + (System.currentTimeMillis() - mStarTime));
}
});
}
}
我们看一下运行效果
因为当前没有HTML文件在Cache目录中不存在, 所以还是加载出了线上的资源文件, 现在我们把HTML文件Push到Cache目录中去再看看效果
adb push local.html /storage/emulated/0/Android/data/com.liujian.hybridproject/cache
[100%] /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/local.html
重新运行之后
可以看出, 已经把我们想要加载出来的内容成功的显示出来了.
看到这里大家应该基本了解了这个方案的主要流程了,下面总结一下这个方案的大致流程
- 把所有的请求都拦截下来
- 根据请求链接判断本地是否有对应的缓存文件
- 如果本地没有缓存文件, 直接返回null, 由WebView去请求
- 如果本地有缓存文件, 根据文件类型, 构造WebResourceResponse返回拦截结果
我们对代码再做一点点修改, 把文件类型和MimeType(MimeType列表参考)做一个关联
首先我们把这一个网页加载中请求的所有链接打印出来(为了方便缓存文件判断,只打印域名为www.dy2018.com的请求)
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/i/97824.html
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/css/dygod.css
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/css/searchpage.css
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/search.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/base64.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/thunderForumLinker.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/js/tj.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/jsdd/f.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/css/index.css?1
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/css/db.css?1
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/images/logo.gif
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/images/menubg.gif
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/images/search_02.gif
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/images/search_01.gif
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/images/search_03.gif
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/images/search_btn.gif
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/images/tbg.gif
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/jsdd/a.js
D/LJTAG: shouldInterceptRequest: http://www.dy2018.com/favicon.ico
然后修改我们的Android代码
public class InterceptActivity extends Activity {
private static HashMap<String, String> sMimeTypeMap = new HashMap<>();
static {
sMimeTypeMap.put("html", "text/html");
sMimeTypeMap.put("css", "text/css");
sMimeTypeMap.put("js", "text/javascript");
sMimeTypeMap.put("gif", "image/gif");
sMimeTypeMap.put("ico", "image/x-icon");
sMimeTypeMap.put("png", "image/png");
sMimeTypeMap.put("jpg", "image/jpeg");
sMimeTypeMap.put("jpeg", "image/jpeg");
}
protected WebView mWebView;
private static final String URL = "http://www.dy2018.com/i/97824.html";
private long mStarTime;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView = (WebView) findViewById(R.id.web_view);
initWebView();
mStarTime = System.currentTimeMillis();
mWebView.loadUrl(URL);
}
private void initWebView() {
WebSettings settings = mWebView.getSettings();
settings.setJavaScriptEnabled(true);
mWebView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
if (url.contains("http://www.dy2018.com")) {
return getCacheResponse(url);
}
return null;
}
@Override
public void onPageFinished(WebView view, String url) {
Log.d("LJTAG", "useTime: " + (System.currentTimeMillis() - mStarTime));
}
});
}
/**
* 根据url获取WebResourceResponse, 如果不存在缓存文件, 返回null
* 否则根据文件名后缀 查找对应的MimeType构造WebResourceResponse, 编码都使用utf-8
*
* @param aimUrl
* @return
*/
private WebResourceResponse getCacheResponse(String aimUrl) {
try {
URL url = new URL(aimUrl);
// Log.d("LJTAG", "getCacheFileUrl: " + url);
// 如果本地已缓存该文件 则返回该文件的file协议地址
// 这里只是简单的以本地SD卡的应用缓存文件夹 作为url根目录拼接起来
// 比如说对于请求 http://www.dy2018.com/i/97729.html
// 在本地的路径就是 getExternalCacheDir() + "/i/97729.html"
// 真实环境中要考虑SD卡是否存在的问题以及具体的缓存目录选择
File cacheFile = new File(getExternalCacheDir(), url.getPath());
if (cacheFile.exists()) {
Log.d("LJTAG", cacheFile.getAbsolutePath() + " 文件存在, 直接拦截请求");
InputStream inputStream = new FileInputStream(cacheFile);
// 这里还可以是一个网络流 比如 InputStream inputStream = new URL("http://www.baidu.com").openConnection().getInputStream()
String fileName = cacheFile.getName();
int lastDot = fileName.lastIndexOf(".");
String suffix = lastDot > -1 ? fileName.substring(lastDot + 1).toLowerCase() : null;
return new WebResourceResponse(sMimeTypeMap.get(suffix), "utf-8", inputStream);
} else {
Log.d("LJTAG", cacheFile.getAbsolutePath() + " 文件不存在");
}
} catch (MalformedURLException e) {
Log.e("LJTAG", e.getMessage());
} catch (FileNotFoundException e) {
Log.e("LJTAG", e.getMessage());
}
return null;
}
}
首先Cache文件夹中没有缓存文件, 运行日志如下, 页面能够成功的加载
...
D/LJTAG: getCacheFileUrl: http://www.dy2018.com/images/menubg.gif
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/images/menubg.gif 文件不存在
D/LJTAG: getCacheFileUrl: http://www.dy2018.com/jsdd/a.js
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/jsdd/a.js 文件不存在
D/LJTAG: getCacheFileUrl: http://www.dy2018.com/images/search_02.gif
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/images/search_02.gif 文件不存在
...
我们把缓存文件放到Cache目录下之后, 日志文件如下, 页面也成功的加载出来, 说明我们的离线资源加载成功
...
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/jsdd/f.js 文件存在, 直接拦截请求
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/e/public/ViewClick 文件不存在
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/css/index.css 文件存在, 直接拦截请求
D/LJTAG: /storage/emulated/0/Android/data/com.liujian.hybridproject/cache/css/db.css 文件存在, 直接拦截请求
...
相对于方案一, 方案二的缓存加载方案更加强大, 相对于H5是透明的, 无任何侵入性, 实现也相对简单, 还能实现图片视频等其他资源的离线缓存, 目前我们项目中用的就是这套离线方案.
以上就是本人对目前的Hybrid 资源缓存方案的总结, 如有不足, 欢迎指正. 第一篇博客, 希望能帮助到大家 共同学习.