关于 WebView 缓存的重写

本贴最后更新于 2944 天前,其中的信息可能已经时异事殊

在现在的 app 中,越来越多的公司开始使用 webview 来进行一些活动页面的展示,甚至一些公司开始使用 webview 做为主要显示组件,把所有的内容都使用 H5 来呈现,这样一来,就对 WebView 的加载速度开始有越来越高的要求,我们要讨论的是 webview 的原本缓存机制所存在的弊端和如何复写 WebView 的缓存机制。

首先说一下 webview 的自带缓存机制的弊端:
webview 的自带缓存机制是无差别缓存,也就是说,不管是页面,样式还是图片,都会缓存到本地,刷新 webview 的缓存一般分为以下几种:

LOAD_CACHE_ONLY: 不使用网络,只读取本地缓存数据
LOAD_DEFAULT: 根据 cache-control 决定是否从网络上取数据。
LOAD_CACHE_NORMAL: API level 17 中已经废弃, 从 API level 11 开始作用同 LOAD_DEFAULT 模式
LOAD_NO_CACHE: 不使用缓存,只从网络获取数据.
LOAD_CACHE_ELSE_NETWORK,只要本地有,无论是否过期,或者 no-cache,都使用缓存中的数据。

反正不管以上几种具体情况如何,都肯定不是我们想要的。

我们想要的机制是:
1.缓存自己想要缓存的内容。
2.指定一个缓存策略,在需要的时候重新去服务器获取最新数据

于是我想到了以下方法
重写

Java 代码

  1. WebViewClient.shouldInterceptRequest(WebView view,WebResourceRequest request)

方法
首先需要新建一个类,继承 WebViewClient:

Java 代码

  1. public class DVDWebViewClient extends WebViewClient

然后实现

Java 代码

  1. public WebResourceResponse shouldInterceptRequest(WebView view,WebResourceRequest request)

方法
shouldInterceptRequest 方法会将所有页面的资源 URL 都一一列举出来,这样一来就好办了,我们似乎只需要缓存自己想要缓存的 url 就可以了。
然后事实是不是这样的呢?

Java 代码

  1. @Override
  2. public WebResourceResponse shouldInterceptRequest(WebView view,
  3. WebResourceRequest request) {
  4. //获取本地的URL主域名
  5. String curDomain = request.getUrl().getHost();
  6. //这行LOG可以不看
  7. if (BuildConfig.DEBUG) {
  8. Log.d(LOG_TAG, "curDomain " + curDomain + " request headers " + request.getRequestHeaders());
  9. for (Map.Entry entry : request.getRequestHeaders().entrySet()) {
  10. Log.d(LOG_TAG, "key=" + entry.getKey() + " #####value=" + entry.getValue() + "\n");
  11. }
  12. }
  13. //取不到domain就直接返回,把接下俩的动作交给webview自己处理
  14. if (curDomain == null || !isPicUrl(curDomain)) {
  15. return null;
  16. }
  17. if (BuildConfig.DEBUG) {
  18. Log.d(LOG_TAG, "shouldInterceptRequest url " + request.getUrl().toString());
  19. }
  20. //读取当前webview正准备加载URL资源
  21. String url = request.getUrl().toString();
  22. try {
  23. //根据资源url获取一个你要缓存到本地的文件名,一般是URL的MD5
  24. String resFileName = getResourcesFileName(url);
  25. if (resFileName == null || "".equals(resFileName)) {
  26. return null;
  27. }
  28. //这里是处理本地缓存的URL,缓存到本地,或者已经缓存过的就直接返回而不去网络进行加载
  29. this.dvdUrlCache.register(url, getResourcesFileName(url),
  30. request.getRequestHeaders().get("Accept"), "UTF-8", DVDUrlCache.ONE_MONTH);
  31. return this.dvdUrlCache.load(url);
  32. } catch (Exception e) {
  33. Log.e(LOG_TAG, "", e);
  34. }
  35. return null;
  36. }

接下来我们看下 DVDUrlCache 的实现:
DVDUrlCache 主要做了这么几件事:
1.封装一个内部类 CacheEntry,做一些基本信息存储

Java 代码

  1. private static class CacheEntry {
  2. //用作存储的URL
  3. public String url;
  4. //本地保存的文件名称
  5. public String fileName;
  6. //标记资源的头部,通过request参数取回
  7. String mimeType;
  8. //需要缓存的资源文件的编码
  9. public String encoding;
  10. //缓存最大有效时间
  11. long maxAgeMillis;
  12. private CacheEntry(String url, String fileName,
  13. String mimeType, String encoding, long maxAgeMillis) {
  14. this.url = url;
  15. this.fileName = fileName;
  16. this.mimeType = mimeType;
  17. this.encoding = encoding;
  18. this.maxAgeMillis = maxAgeMillis;
  19. }
  20. }

接下来是类的构造放方法以及需要映射的 map

Java 代码

  1. //Key 为 URL
  2. private Map cacheEntries = new HashMap<>();
  3. //缓存路径的根目录
  4. private File rootDir = null;
  5. DVDUrlCache() {
  6. //获取本地缓存路径,这个请在调试中自行修改
  7. this.rootDir = DiskUtil.getDiskCacheDir(DVDApplicationContext.getInstance().getApplicationContext());
  8. }
  9. //资源注册,参考 DVDWebViewClient 的调用
  10. public void register(String url, String cacheFileName,
  11. String mimeType, String encoding,
  12. long maxAgeMillis) {
  13. CacheEntry entry = new CacheEntry(url, cacheFileName, mimeType, encoding, maxAgeMillis);
  14. this.cacheEntries.put(url, entry);
  15. }

然后是核心内容

Java 代码

  1. public WebResourceResponse load(final String url) {
  2. try {
  3. final CacheEntry cacheEntry = this.cacheEntries.get(url);
  4. if (cacheEntry == null) {
  5. return null;
  6. }
  7. final File cachedFile = new File(this.rootDir.getPath() + File.separator + cacheEntry.fileName);
  8. if (BuildConfig.DEBUG) {
  9. Log.d(LOG_TAG, "cachedFile is " + cachedFile);
  10. }
  11. if (cachedFile.exists() && isReadFromCache(url)) {
  12. //还没有下载完,在快速切换URL的时候,可能会有很多task并没有及时完成,所以这里需要一个map用于存储正在下载的URL,下载完成后需要移除相应的task
  13. if (queueMap.containsKey(url)) {
  14. return null;
  15. }
  16. //过期后直接删除本地缓存内容
  17. long cacheEntryAge = System.currentTimeMillis() - cachedFile.lastModified();
  18. if (cacheEntryAge > cacheEntry.maxAgeMillis) {
  19. cachedFile.delete();
  20. if (BuildConfig.DEBUG) {
  21. Log.d(LOG_TAG, "Deleting from cache: " + url);
  22. }
  23. return null;
  24. }
  25. //cached file exists and is not too old. Return file.
  26. if (BuildConfig.DEBUG) {
  27. Log.d(LOG_TAG, url + " ### cache file : " + cachedFile.getAbsolutePath());
  28. }
  29. return new WebResourceResponse(
  30. cacheEntry.mimeType, cacheEntry.encoding, new FileInputStream(cachedFile));
  31. } else {
  32. if (!queueMap.containsKey(url)) {
  33. queueMap.put(url, new Callable() {
  34. @Override
  35. public Boolean call() throws Exception {
  36. return downloadAndStore(url, cacheEntry);
  37. }
  38. });
  39. final FutureTask futureTask = ThreadPoolManager.getInstance().addTaskCallback(queueMap.get(url));
  40. ThreadPoolManager.getInstance().addTask(new Runnable() {
  41. @Override
  42. public void run() {
  43. try {
  44. if (futureTask.get()) {
  45. if (BuildConfig.DEBUG) {
  46. Log.d(LOG_TAG, "remove " + url);
  47. }
  48. queueMap.remove(url);
  49. }
  50. } catch (InterruptedException | ExecutionException e) {
  51. Log.d(LOG_TAG, "", e);
  52. }
  53. }
  54. });
  55. }
  56. }
  57. } catch (Exception e) {
  58. Log.d(LOG_TAG, "Error reading file over network: ", e);
  59. }
  60. return null;
  61. }
  62. //这个方法是资源下载
  63. private boolean downloadAndStore(final String url, final CacheEntry cacheEntry)
  64. throws IOException {
  65. FileOutputStream fileOutputStream = null;
  66. InputStream urlInput = null;
  67. try {
  68. URL urlObj = new URL(url);
  69. URLConnection urlConnection = urlObj.openConnection();
  70. urlInput = urlConnection.getInputStream();
  71. String tempFilePath = DVDUrlCache.this.rootDir.getPath() + File.separator + cacheEntry.fileName + ".temp";
  72. File tempFile = new File(tempFilePath);
  73. fileOutputStream = new FileOutputStream(tempFile);
  74. byte[] buffer = new byte[1024];
  75. int length;
  76. while ((length = urlInput.read(buffer)) > 0) {
  77. fileOutputStream.write(buffer, 0, length);
  78. }
  79. fileOutputStream.flush();
  80. File lastFile = new File(tempFilePath.replace(".temp", ""));
  81. boolean renameResult = tempFile.renameTo(lastFile);
  82. if (!renameResult) {
  83. Log.w(LOG_TAG, "rename file failed, " + tempFilePath);
  84. }
  85. // Log.d(LOG_TAG, "Cache file: " + cacheEntry.fileName + " stored. ");
  86. return true;
  87. } catch (Exception e) {
  88. Log.e(LOG_TAG, "", e);
  89. } finally {
  90. if (urlInput != null) {
  91. urlInput.close();
  92. }
  93. if (fileOutputStream != null) {
  94. fileOutputStream.close();
  95. }
  96. }
  97. return false;
  98. }
  99. private boolean isReadFromCache(String url) {
  100. return true;
  101. }

完整的 DVDURLCache 代码,方便大家直接 copy

Java 代码

  1. package com.davdian.seller.util.WebUtil;
  2. import android.util.Log;
  3. import android.webkit.WebResourceResponse;
  4. import com.davdian.seller.BuildConfig;
  5. import com.davdian.seller.global.DVDApplicationContext;
  6. import com.davdian.seller.util.DiskUtil;
  7. import com.davdian.seller.util.ThreadPoolManager;
  8. import java.io.*;
  9. import java.net.URL;
  10. import java.net.URLConnection;
  11. import java.util.HashMap;
  12. import java.util.LinkedHashMap;
  13. import java.util.Map;
  14. import java.util.concurrent.Callable;
  15. import java.util.concurrent.ExecutionException;
  16. import java.util.concurrent.FutureTask;
  17. /**
    • 只缓存图片的自定义接口
    • Created by hongminghuangfu on 16/9/3.
  18. */
  19. public class DVDUrlCache {
  20. private static final String LOG_TAG = "DVDUrlCache";
  21. private static final long ONE_SECOND = 1000L;
  22. private static final long ONE_MINUTE = 60L * ONE_SECOND;
  23. static final long ONE_HOUR = 60 * ONE_MINUTE;
  24. static final long ONE_DAY = 24 * ONE_HOUR;
  25. static final long ONE_MONTH = 30 * ONE_DAY;
  26. private static final LinkedHashMap> queueMap = new LinkedHashMap<>();
  27. private static class CacheEntry {
  28. public String url;
  29. public String fileName;
  30. String mimeType;
  31. public String encoding;
  32. long maxAgeMillis;
  33. private CacheEntry(String url, String fileName,
  34. String mimeType, String encoding, long maxAgeMillis) {
  35. this.url = url;
  36. this.fileName = fileName;
  37. this.mimeType = mimeType;
  38. this.encoding = encoding;
  39. this.maxAgeMillis = maxAgeMillis;
  40. }
  41. }
  42. private Map cacheEntries = new HashMap<>();
  43. private File rootDir = null;
  44. DVDUrlCache() {
  45. //本地缓存路径,请在调试中自行修改
  46. this.rootDir = DiskUtil.getDiskCacheDir(DVDApplicationContext.getInstance().getApplicationContext());
  47. }
  48. public void register(String url, String cacheFileName,
  49. String mimeType, String encoding,
  50. long maxAgeMillis) {
  51. CacheEntry entry = new CacheEntry(url, cacheFileName, mimeType, encoding, maxAgeMillis);
  52. this.cacheEntries.put(url, entry);
  53. }
  54. public WebResourceResponse load(final String url) {
  55. try {
  56. final CacheEntry cacheEntry = this.cacheEntries.get(url);
  57. if (cacheEntry == null) {
  58. return null;
  59. }
  60. final File cachedFile = new File(this.rootDir.getPath() + File.separator + cacheEntry.fileName);
  61. if (BuildConfig.DEBUG) {
  62. Log.d(LOG_TAG, "cachedFile is " + cachedFile);
  63. }
  64. if (cachedFile.exists() && isReadFromCache(url)) {
  65. //还没有下载完
  66. if (queueMap.containsKey(url)) {
  67. return null;
  68. }
  69. long cacheEntryAge = System.currentTimeMillis() - cachedFile.lastModified();
  70. if (cacheEntryAge > cacheEntry.maxAgeMillis) {
  71. cachedFile.delete();
  72. if (BuildConfig.DEBUG) {
  73. Log.d(LOG_TAG, "Deleting from cache: " + url);
  74. }
  75. return null;
  76. }
  77. //cached file exists and is not too old. Return file.
  78. if (BuildConfig.DEBUG) {
  79. Log.d(LOG_TAG, url + " ### cache file : " + cachedFile.getAbsolutePath());
  80. }
  81. return new WebResourceResponse(
  82. cacheEntry.mimeType, cacheEntry.encoding, new FileInputStream(cachedFile));
  83. } else {
  84. if (!queueMap.containsKey(url)) {
  85. queueMap.put(url, new Callable() {
  86. @Override
  87. public Boolean call() throws Exception {
  88. return downloadAndStore(url, cacheEntry);
  89. }
  90. });
  91. final FutureTask futureTask = ThreadPoolManager.getInstance().addTaskCallback(queueMap.get(url));
  92. ThreadPoolManager.getInstance().addTask(new Runnable() {
  93. @Override
  94. public void run() {
  95. try {
  96. if (futureTask.get()) {
  97. if (BuildConfig.DEBUG) {
  98. Log.d(LOG_TAG, "remove " + url);
  99. }
  100. queueMap.remove(url);
  101. }
  102. } catch (InterruptedException | ExecutionException e) {
  103. Log.d(LOG_TAG, "", e);
  104. }
  105. }
  106. });
  107. }
  108. }
  109. } catch (Exception e) {
  110. Log.d(LOG_TAG, "Error reading file over network: ", e);
  111. }
  112. return null;
  113. }
  114. private boolean downloadAndStore(final String url, final CacheEntry cacheEntry)
  115. throws IOException {
  116. FileOutputStream fileOutputStream = null;
  117. InputStream urlInput = null;
  118. try {
  119. URL urlObj = new URL(url);
  120. URLConnection urlConnection = urlObj.openConnection();
  121. urlInput = urlConnection.getInputStream();
  122. String tempFilePath = DVDUrlCache.this.rootDir.getPath() + File.separator + cacheEntry.fileName + ".temp";
  123. File tempFile = new File(tempFilePath);
  124. fileOutputStream = new FileOutputStream(tempFile);
  125. byte[] buffer = new byte[1024];
  126. int length;
  127. while ((length = urlInput.read(buffer)) > 0) {
  128. fileOutputStream.write(buffer, 0, length);
  129. }
  130. fileOutputStream.flush();
  131. File lastFile = new File(tempFilePath.replace(".temp", ""));
  132. boolean renameResult = tempFile.renameTo(lastFile);
  133. if (!renameResult) {
  134. Log.w(LOG_TAG, "rename file failed, " + tempFilePath);
  135. }
  136. // Log.d(LOG_TAG, "Cache file: " + cacheEntry.fileName + " stored. ");
  137. return true;
  138. } catch (Exception e) {
  139. Log.e(LOG_TAG, "", e);
  140. } finally {
  141. if (urlInput != null) {
  142. urlInput.close();
  143. }
  144. if (fileOutputStream != null) {
  145. fileOutputStream.close();
  146. }
  147. }
  148. return false;
  149. }
  150. private boolean isReadFromCache(String url) {
  151. return true;
  152. }
  153. }

ThreadPoolManager 很简单:

Java 代码

  1. package com.davdian.seller.util;
  2. import android.util.Log;
  3. import java.util.concurrent.Callable;
  4. import java.util.concurrent.ExecutorService;
  5. import java.util.concurrent.Executors;
  6. import java.util.concurrent.FutureTask;
  7. /**
    • 线程池
    • Created by hongminghuangfu on 16/9/9.
  8. */
  9. public class ThreadPoolManager {
  10. private static final String LOG_TAG = "ThreadPoolManager";
  11. private static final ThreadPoolManager instance = new ThreadPoolManager();
  12. private ExecutorService threadPool = Executors.newFixedThreadPool(100);
  13. public static ThreadPoolManager getInstance() {
  14. return instance;
  15. }
  16. /**
  17. * @param runnable 不返回执行结果的异步任务
  18. */
  19. public void addTask(Runnable runnable) {
  20. try {
  21. if (runnable != null) {
  22. threadPool.execute(runnable);
  23. }
  24. } catch (Exception e) {
  25. Log.e(LOG_TAG, "", e);
  26. }
  27. }
  28. /**
  29. * @param callback 异步任务
  30. * @return 你可以获取相应的执行结果
  31. */
  32. public FutureTask addTaskCallback(Callable callback) {
  33. if (callback == null) {
  34. return null;
  35. } else {
  36. FutureTask futureTask = new FutureTask<>(callback);
  37. threadPool.submit(futureTask);
  38. return futureTask;
  39. }
  40. }
  41. // 这是一个 demo,如果你看不懂,可以打开跑一下
  42. // public static void main(String args[]) {
  43. // FutureTask ft = ThreadPoolManager.getInstance().addTaskCallback(new Callable() {
  44. // @Override
  45. // public Object call() throws Exception {
  46. // int sum = 0;
  47. // for (int i = 0; i < 1000; i++) {
  48. // sum++;
  49. // }
  50. // return sum;
  51. // }
  52. // });
  53. // try {
  54. // System.out.println("执行结果是:" + ft.get());
  55. // } catch (InterruptedException | ExecutionException e) {
  56. // e.printStackTrace();
  57. // }
  58. //
  59. // }
  60. }
  • Android

    Android 是一种以 Linux 为基础的开放源码操作系统,主要使用于便携设备。2005 年由 Google 收购注资,并拉拢多家制造商组成开放手机联盟开发改良,逐渐扩展到到平板电脑及其他领域上。

    335 引用 • 324 回帖
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3195 引用 • 8215 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • Vanessa via macOS

    被你的图片骗进来了。为嘛要用图片打那么多的星呢? emoji 多好,还好看。 ⭐️ 🌟

推荐标签 标签

  • HHKB

    HHKB 是富士通的 Happy Hacking 系列电容键盘。电容键盘即无接点静电电容式键盘(Capacitive Keyboard)。

    5 引用 • 74 回帖 • 496 关注
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    24954 引用 • 102785 回帖
  • C++

    C++ 是在 C 语言的基础上开发的一种通用编程语言,应用广泛。C++ 支持多种编程范式,面向对象编程、泛型编程和过程化编程。

    107 引用 • 153 回帖
  • Ruby

    Ruby 是一种开源的面向对象程序设计的服务器端脚本语言,在 20 世纪 90 年代中期由日本的松本行弘(まつもとゆきひろ/Yukihiro Matsumoto)设计并开发。在 Ruby 社区,松本也被称为马茨(Matz)。

    7 引用 • 31 回帖 • 255 关注
  • Gzip

    gzip (GNU zip)是 GNU 自由软件的文件压缩程序。我们在 Linux 中经常会用到后缀为 .gz 的文件,它们就是 Gzip 格式的。现今已经成为互联网上使用非常普遍的一种数据压缩格式,或者说一种文件格式。

    9 引用 • 12 回帖 • 167 关注
  • 博客

    记录并分享人生的经历。

    273 引用 • 2388 回帖
  • Elasticsearch

    Elasticsearch 是一个基于 Lucene 的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful 接口。Elasticsearch 是用 Java 开发的,并作为 Apache 许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。

    117 引用 • 99 回帖 • 207 关注
  • Word
    13 引用 • 40 回帖 • 1 关注
  • 资讯

    资讯是用户因为及时地获得它并利用它而能够在相对短的时间内给自己带来价值的信息,资讯有时效性和地域性。

    56 引用 • 85 回帖 • 1 关注
  • OnlyOffice
    4 引用 • 21 关注
  • Ubuntu

    Ubuntu(友帮拓、优般图、乌班图)是一个以桌面应用为主的 Linux 操作系统,其名称来自非洲南部祖鲁语或豪萨语的“ubuntu”一词,意思是“人性”、“我的存在是因为大家的存在”,是非洲传统的一种价值观,类似华人社会的“仁爱”思想。Ubuntu 的目标在于为一般用户提供一个最新的、同时又相当稳定的主要由自由软件构建而成的操作系统。

    127 引用 • 169 回帖
  • WebSocket

    WebSocket 是 HTML5 中定义的一种新协议,它实现了浏览器与服务器之间的全双工通信(full-duplex)。

    48 引用 • 206 回帖 • 298 关注
  • 架构

    我们平时所说的“架构”主要是指软件架构,这是有关软件整体结构与组件的抽象描述,用于指导软件系统各个方面的设计。另外还有“业务架构”、“网络架构”、“硬件架构”等细分领域。

    143 引用 • 442 回帖
  • Follow
    4 引用 • 12 回帖 • 11 关注
  • 自由行
    2 关注
  • Sym

    Sym 是一款用 Java 实现的现代化社区(论坛/BBS/社交网络/博客)系统平台。

    下一代的社区系统,为未来而构建

    524 引用 • 4601 回帖 • 702 关注
  • 微信

    腾讯公司 2011 年 1 月 21 日推出的一款手机通讯软件。用户可以通过摇一摇、搜索号码、扫描二维码等添加好友和关注公众平台,同时可以将自己看到的精彩内容分享到微信朋友圈。

    132 引用 • 796 回帖
  • FreeMarker

    FreeMarker 是一款好用且功能强大的 Java 模版引擎。

    23 引用 • 20 回帖 • 456 关注
  • API

    应用程序编程接口(Application Programming Interface)是一些预先定义的函数,目的是提供应用程序与开发人员基于某软件或硬件得以访问一组例程的能力,而又无需访问源码,或理解内部工作机制的细节。

    79 引用 • 431 回帖
  • 深度学习

    深度学习(Deep Learning)是机器学习的分支,是一种试图使用包含复杂结构或由多重非线性变换构成的多个处理层对数据进行高层抽象的算法。

    53 引用 • 40 回帖 • 1 关注
  • Logseq

    Logseq 是一个隐私优先、开源的知识库工具。

    Logseq is a joyful, open-source outliner that works on top of local plain-text Markdown and Org-mode files. Use it to write, organize and share your thoughts, keep your to-do list, and build your own digital garden.

    7 引用 • 69 回帖 • 1 关注
  • SQLite

    SQLite 是一个进程内的库,实现了自给自足的、无服务器的、零配置的、事务性的 SQL 数据库引擎。SQLite 是全世界使用最为广泛的数据库引擎。

    5 引用 • 7 回帖 • 1 关注
  • Tomcat

    Tomcat 最早是由 Sun Microsystems 开发的一个 Servlet 容器,在 1999 年被捐献给 ASF(Apache Software Foundation),隶属于 Jakarta 项目,现在已经独立为一个顶级项目。Tomcat 主要实现了 JavaEE 中的 Servlet、JSP 规范,同时也提供 HTTP 服务,是市场上非常流行的 Java Web 容器。

    162 引用 • 529 回帖 • 4 关注
  • 国际化

    i18n(其来源是英文单词 internationalization 的首末字符 i 和 n,18 为中间的字符数)是“国际化”的简称。对程序来说,国际化是指在不修改代码的情况下,能根据不同语言及地区显示相应的界面。

    8 引用 • 26 回帖 • 1 关注
  • 正则表达式

    正则表达式(Regular Expression)使用单个字符串来描述、匹配一系列遵循某个句法规则的字符串。

    31 引用 • 94 回帖
  • BookxNote

    BookxNote 是一款全新的电子书学习工具,助力您的学习与思考,让您的大脑更高效的记忆。

    笔记整理交给我,一心只读圣贤书。

    1 引用 • 1 回帖 • 1 关注
  • Docker

    Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的操作系统上。容器完全使用沙箱机制,几乎没有性能开销,可以很容易地在机器和数据中心中运行。

    494 引用 • 928 回帖 • 1 关注