一、业务背景
目前,vivo 平台有很多的业务都波及到文件的下载:譬如说利用商店、游戏核心的 C 端用户下载更新利用或游戏;开放平台 B 端用户通过接口传包能力更新利用或游戏,须要从用户服务器上下载 apk、图片等文件,来实现用户的一次版本更新。
二、面临的挑战
针对上述 C 端用户,平台须要提供良好的下载环境,并且客户端须要兼容手机上用户的异样操作。
针对上述 B 端用户,平台亟需解决的问题就是从用户服务器上,拉取各种资源文件。
下载自身也是一个很简单的问题,会波及到网络问题、URL 重定向、超大文件、近程服务器文件变更、本地文件被删除等各种问题。这就须要咱们保障平台具备疾速下载文件的能力,同时兼具备有对异样场景的疾速预警、容错解决的机制。
三、业务实现计划
基于后面提到的挑战,咱们设计实现计划的时候,援用了行业罕用的解决办法:断点下载。
针对 B 端用户场景,咱们的解决计划入下图:
一、极速下载 :通过剖析文件大小,智能抉择是否采纳间接下载、单线程断点下载、多线程断点下载的计划;在应用多线程下载计划时,对 ” 多线程 ” 的应用,有两种形式:
- 分组模式: 单个文件采纳固定最大 N 个线程来进行下载,分组的益处是能保障服务节点线程数量可控,劣势就是遇到大文件的时候,下载耗时绝对会比拟长;
- 分片模式: 采纳单个线程,固定下载 N 个字节大小空间,分片的益处是遇到大文件的时候,下载耗时依然会绝对短,劣势是会导致服务器节点线程数量突增,对服务节点稳定性有烦扰;
在二者之间,咱们抉择了分组模式。
二、容错解决: 在咱们解决下载过程中,会遇到下载过程中网络不稳固、本地文件删除,近程文件变更等各种场景,这就须要咱们可能兼容解决这些场景,失败后的工作,会有定时工作主动从新调起执行,也有后盾管理系统界面,进行人工调起;
三、完整性校验: 文件下载实现之后,须要对文件的最终一致性做校验,来确保文件的正确性;
四、异样预警: 对于单次工作在尝试屡次下载操作后依然失败的状况,及时发动预警正告。
对于 C 端用户,业务计划绝对更简略,因为文件服务器有 vivo 平台提供,网络环境绝对可控,这里就不再赘述。接下来,咱们将对文件下载外面的各种技术细节,进行详尽的分析。
四、断点下载原理分析
在进行原理剖析前,先给大家遍及一下,什么叫断点下载?置信大家都有过应用迅雷下载网络文件的经验吧,有没有留神到迅雷的下载任务栏外面,有一个“暂停”和“开始下载”按钮,会随着工作的以后状态显示不同的按钮。当你在下载一个 100M 的文件,下载到 50M 的时候,你点击了“暂停”,而后点击了“开始下载”,你会发现文件的下载居然是从曾经下载好的 50M 当前接着下载的。没错,这就是断点下载的实在利用。
4.1 HTTP 断点下载之机密:Range
在解说这个知识点前,大家有必要理解一下 http 的倒退历史,HTTP(HyperText Transfer Protocol),超文本传输协定,是目前万维网(World Wide Web)的根底协定,曾经经验四次的版本迭代:HTTP/0.9,HTTP/1.0,HTTP/1.1,HTTP/2.0。在 HTTP/1.1(RFC2616)协定中,定义了 HTTP1.1 规范所蕴含的所有头字段的相干语法和含意,其中就包含咱们要讲到的 Accept-Ranges,服务端反对范畴申请(range requests)。有了这个重要的属性,才使得咱们的断点下载成为可能。
基于 HTTP 不同版本之间的适配性,所以当咱们在决定是否须要应用断点下载能力的时候,须要提前辨认文件地址是否反对断点下载,怎么辨认呢?办法很多,如果采纳 curl 命令,命令为:curl -I url
CURL 验证是否反对范畴申请:
如果服务端的响应信息外面蕴含了上图中 Accept-Ranges: bytes,这个属性,那么说该 URL 是反对范畴申请的。如果 URL 返回音讯体外面,Accept-Ranges: none 或者压根就没有 Accept-Ranges 这个属性,那么这个 URL 就是不反对范畴申请,也就是不反对断点下载。
后面咱们有看到,当应用 curl 命令获取 URL 的响应时,服务端返回了一大段文本信息,咱们要实现文件的断点下载,就要从这些文本信息外面获取咱们断点下载须要的重要参数,有了这些参数后能力实现咱们想要达到的成果。
4.2 HTTP 断点下载之 Range 语法阐明
HTTP/1.1 中定义了一个 Range 的申请头,来指定申请实体的范畴。它的范畴取值是在 0 – Content-Length 之间,应用 – 宰割。
4.2.1 单区间段范畴申请
curl https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt -i -H "Range: bytes=0-100"
HTTP/1.1 206 Partial Content
Date: Sun, 20 Dec 2020 03:06:43 GMT
Content-Type: text/plain
Content-Length: 101
Connection: keep-alive
Server: AliyunOSS
x-oss-request-id: 5FDEBFC33243A938379F9410
Accept-Ranges: bytes
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
x-oss-object-type: Normal
x-oss-hash-crc64ecma: 5148872045942545519
x-oss-storage-class: Standard
Content-MD5: H/02vRsG62woevjXiEWICA==
x-oss-server-time: 2
Content-Range: bytes 0-100/740
X-Via: 1.1 PShnzssxek171:14 (Cdn Cache Server V2.0), 1.1 x71:12 (Cdn Cache Server V2.0), 1.1 PS-FOC-01z6n168:27 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdebfc3_PS-FOC-01z6n168_36519-1719
Access-Control-Allow-Origin: *
4.2.2 多区间段范畴申请
curl https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt -i -H "Range: bytes=0-100,200-300"
HTTP/1.1 206 Partial Content
Date: Sun, 20 Dec 2020 03:10:27 GMT
Content-Type: multipart/byteranges; boundary="Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A"
Content-Length: 506
Connection: keep-alive
Server: AliyunOSS
x-oss-request-id: 5FDEC030BDB66C33302A497E
Accept-Ranges: bytes
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
x-oss-object-type: Normal
x-oss-hash-crc64ecma: 5148872045942545519
x-oss-storage-class: Standard
Content-MD5: H/02vRsG62woevjXiEWICA==
x-oss-server-time: 2
Age: 1
X-Via: 1.1 xian23:7 (Cdn Cache Server V2.0), 1.1 PS-NTG-01KKN43:8 (Cdn Cache Server V2.0), 1.1 PS-FOC-01z6n168:27 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdec0a3_PS-FOC-01z6n168_36013-8986
Access-Control-Allow-Origin: *
--Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A
Content-Type: text/plain
Content-Range: bytes 0-100/740
--Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A
Content-Type: text/plain
Content-Range: bytes 200-300/740
看完上述申请的响应后果信息,咱们发现应用单范畴区间申请时:Content-Type: text/plain,应用多范畴区间申请时:Content-Type: multipart/byteranges; boundary=”Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A”,并且在尾部信息外面,携带了单个区间片段的 Content-Type 和 Content-Range。另外,不晓得大家有没有发现一个很重要的信息,咱们的 HTTP 响应的状态并非咱们料想中的 200,而是 HTTP/1.1 206 Partial Content,这个状态码十分重要,因为它标识着当次下载是否反对范畴申请。
4.3 异样场景之资源变更
有一种场景,不晓得大家有没有思考过,就是咱们在下载一个大文件的时候,在未下载实现的时候,近程文件曾经产生了变更,如果咱们持续应用断点下载,会呈现什么样的问题?后果当然是文件与近程文件不统一,会导致文件不可用。那么咱们有什么方法可能在下载之前及时发现近程文件曾经变更,并及时进行调整下载计划呢?解决办法其实下面有给大家提到,近程文件有没有发生变化,有两个标识:Etag 和 Last-Modified。二者任意一个属性均可反馈进去,相比而言,Etag 会更精准些,起因如下:
- Last-Modified 只能准确到秒级别,如果一秒内文件进行了屡次批改,工夫不会产生更新,然而文件的内容却曾经产生了变更,此时 Etag 会及时更新辨认到变更;
- 在不同的工夫节点(超过 1 秒),如果文件从 A 状态改成 B 状态,而后又重 B 状态改回了 A 状态,工夫会产生更新,然而相较于 A 状态文件内容,两次变更后并没又发生变化,此时 Etag 会变回最开始 A 状态值,有点相似咱们并发编程外面常说的 ABA 问题。
如果咱们在进行范畴申请下载的时候,带上了这两个属性中的一个或两个,就能监控近程文件产生了变动。如果产生了变动,那么区间范畴申请的响应状态就不是 206 而是 200,阐明它曾经不反对该次申请的断点下载了。接下来咱们验证一下 Etag 的验证信息,咱们的测试文件:ETag: “1FFD36BD1B06EB6C287AF8D788458808″,而后咱们将最初一个数值 8 改成 9 进行验证,验证如下:
文件未变更:
curl -I --header 'If-None-Match:"1FFD36BD1B06EB6C287AF8D788458808"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt
HTTP/1.1 304 Not Modified
Date: Sun, 20 Dec 2020 03:53:03 GMT
Content-Type: text/plain
Connection: keep-alive
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Age: 1
X-Via: 1.1 PS-FOC-01vM6221:15 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdeca9f_PS-FOC-01FMC220_2660-18267
Access-Control-Allow-Origin: *
文件已变更:
curl -I --header 'If-None-Match:"1FFD36BD1B06EB6C287AF8D788458809"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt
HTTP/1.1 200 OK
Date: Sun, 20 Dec 2020 03:53:14 GMT
Content-Type: text/plain
Content-Length: 740
Connection: keep-alive
Server: AliyunOSS
x-oss-request-id: 5FDEC837E677A23037926897
Accept-Ranges: bytes
ETag: "1FFD36BD1B06EB6C287AF8D788458808"
Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT
x-oss-object-type: Normal
x-oss-hash-crc64ecma: 5148872045942545519
x-oss-storage-class: Standard
Content-MD5: H/02vRsG62woevjXiEWICA==
x-oss-server-time: 17
X-Cache-Spec: Yes
Age: 1
X-Via: 1.1 xian23:7 (Cdn Cache Server V2.0), 1.1 PS-NTG-01KKN43:8 (Cdn Cache Server V2.0), 1.1 PS-FOC-01vM6221:15 (Cdn Cache Server V2.0)
X-Ws-Request-Id: 5fdecaaa_PS-FOC-01FMC220_4661-42392
Access-Control-Allow-Origin: *
结果显示:当咱们应用跟近程文件统一的 Etag 时,状态码返回:HTTP/1.1 304 Not Modified,而应用篡改后的 Etag 后,返回状态 200,并且也携带了正确的 Etag 返回。所以咱们在应用断点下载过程中,对于这种资源变更的场景也是须要兼顾思考的,不然就会呈现下载后文件无奈应用状况。
4.4 完整性验证
文件在下载实现后,咱们是不是就能间接应用呢?答案:NO。因为咱们无奈确认文件是否跟近程文件完全一致,所以在应用前,肯定要做一次文件的完整性验证。验证办法很简略,就是咱们后面提到过的属性:Etag,资源版本的标识符,通常是音讯摘要。带双引号的 32 位字符串,笔者验证过,该属性移除双引号后,就是文件的 MD5 值,大家晓得,文件 MD5 是能够用来验证文件唯一性的标识。通过这个校验,就能很好的辨认解决本地文件被删除、近程资源文件变更的各类非常规的业务场景。
五、实际局部
5.1 单线程断点下载
如果咱们须要下载 1000 个字节大小的文件,那么咱们在开始下载的时候,首先会获取到文件的 Content-Length,而后在第一次开始下载时,会应用参数:httpURLConnection.setRequestProperty(“Range”, “bytes=0-1000”);
当下载到到 150 个字节大小的时候,因为网络问题或者客户端服务重启等状况,导致下载终止,那么本地就存在一个大小为 150byte 的不残缺文件,当咱们服务重启后从新下载该文件时,咱们不仅须要从新获取近程文件的大小,还须要获取本地曾经下载的文件大小,此时应用参数:httpURLConnection.setRequestProperty(“Range”, “bytes=150-1000”);
来保障咱们的下载是基于前一次的下载根底之上的。图示:
5.2 多线程断点下载
多线程断点下载的原理,与下面提到的单线程相似,惟一的区别在于:多个线程并行下载,单线程是串行下载。
5.3 代码示例
5.3.1 获取连贯
在下载前,咱们须要获取近程文件的 HttpURLConnection 连贯,如下:
/**
* 获取连贯
*/
private static HttpURLConnection getHttpUrlConnection(String netUrl) throws Exception {URL url = new URL(netUrl);
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
// 设置超工夫为 3 秒
httpURLConnection.setConnectTimeout(3 * 1000);
// 避免屏蔽程序抓取而返回 403 谬误
httpURLConnection.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
return httpURLConnection;
}
5.3.2 是否反对范畴申请
在进行断点下载开始前,咱们须要判断该文件,是否反对范畴申请,反对的范畴申请,咱们能力实现断点下载,如下:
/**
* 判断连贯是否反对断点下载
*/
private static boolean isSupportRange(String netUrl) throws Exception {HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
String acceptRanges = httpURLConnection.getHeaderField("Accept-Ranges");
if (StringUtils.isEmpty(acceptRanges)) {return false;}
if ("bytes".equalsIgnoreCase(acceptRanges)) {return true;}
return false;
}
5.3.3 获取近程文件大小
当文件反对断点下载,咱们须要获取近程文件的大小,来设置 Range 参数的范畴区间,当然,如果是单线程断线下载,不获取近程文件大小,应用 Range: start- 也是能实现断点下载的,如下:
/**
* 获取近程文件大小
*/
private static int getFileContentLength(String netUrl) throws Exception {HttpURLConnection httpUrlConnection = getHttpUrlConnection(netUrl);
int contentLength = httpUrlConnection.getContentLength();
closeHttpUrlConnection(httpUrlConnection);
return contentLength;
}
5.3.4 单线程断点下载
不论是单线程断点下载还是多线程断点下载,片段文件下载实现后,都无奈绕开的一个问题,那就是文件合并。咱们应用范畴申请,拿到了文件中的某个区间片段,最终还是要将各个片段合并成一个残缺的文件,能力实现咱们最后的下载目标。
相较而言,单线程的合并会比较简单,因为单线程断点下载应用串行下载,在文件断点写入过程中,都是基于已有片段进行尾部追加,咱们应用 commons-io-2.4.jar 外面的一个工具办法,来实现文件的尾部追加:
5.3.4.1 文件分段
单线程 - 范畴分段
/**
* 单线程串行下载
*
* @param totalFileSize 文件总大小
* @param netUrl 文件地址
* @param N 串行下载分段次数
*/
private static void segmentDownload(int totalFileSize, String netUrl, int N) throws Exception {
// 本地文件目录
String localFilePath = "F:\\test_single_thread.txt";
// 文件咱们分 N 次来下载
int eachFileSize = totalFileSize / N;
for (int i = 1; i <= N; i++) {
// 写入本地文件
File localFile = new File(localFilePath);
// 获取本地文件,如果为空,则 start=0,不为空则为该本地文件的大小作为断点下载开始地位
long start = localFile.length();
long end = 0;
if (i == 1) {end = eachFileSize;} else if (i == N) {end = totalFileSize;} else {end = eachFileSize * i;}
appendFile(netUrl, localFile, start, end);
System.out.println(String.format("我是第 %s 次下载,下载片段范畴 start=%s,end=%s", i, start, end));
}
File localFile = new File(localFilePath);
System.out.println("本地文件大小:" + localFile.length());
}
5.3.4.2 文件追加
单线程 - 文件尾部追加
/**
* 文件尾部追加
* @param netUrl 地址
* @param localFile 本地文件
* @param start 分段开始地位
* @param end 分段完结地位
*/
private static void appendFile(String netUrl, File localFile, long start, long end) throws Exception {HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
httpURLConnection.setRequestProperty("Range", "bytes=" + start + "-" + end);
// 获取近程文件流信息
InputStream inputStream = httpURLConnection.getInputStream();
// 本地文件写入流,反对文件追加
FileOutputStream fos = FileUtils.openOutputStream(localFile, true);
IOUtils.copy(inputStream, fos);
closeHttpUrlConnection(httpURLConnection);
}
单线程下载后果
近程文件反对断点下载
近程文件大小:740
我是第 1 次下载,下载片段范畴 start=0,end=246
我是第 2 次下载,下载片段范畴 start=247,end=492
我是第 3 次下载,下载片段范畴 start=493,end=740
本地文件和近程文件统一,md5 = 1FFD36BD1B06EB6C287AF8D788458808, Etag = "1FFD36BD1B06EB6C287AF8D788458808"
5.3.5 多线程断点下载
多线程的文件合并形式与单线程不一样,因为多线程是并行下载,每个子线程下载实现的工夫是不确定的。这个时候,咱们须要应用到 java 一个外围类:RandomAccessFile。这个类能够反对随机的文件读写,其中有一个 seek 函数,能够将指针指向文件任意地位,而后进行读写。什么意思呢,举个栗子:如果咱们开了 10 个线程,首先第一个下载实现的是线程 X,它下载的数据范畴是 300-400,那么这时咱们调用 seek 函数将指针动到 300,而后调用它的 write 函数将 byte 写出,这时候 300 之前都是 NULL,300-400 之后就是咱们插入的数据。这样就能够实现多线程下载和本地写入了。话不多说,咱们还是以代码的形式来出现:
5.3.5.1 资源分组
多线程 - 资源分组
/**
* 多线程分组策略
* @param netUrl 网络地址
* @param totalFileSize 文件总大小
* @param N 线程池数量
*/
private static void groupDownload(String netUrl, int totalFileSize, int N) throws Exception {
// 采纳闭锁个性来实现最初的文件校验事件
CountDownLatch countDownLatch = new CountDownLatch(N);
// 本地文件目录
String localFilePath = "F:\\test_multiple_thread.txt";
int groupSize = totalFileSize / N;
int start = 0;
int end = 0;
for (int i = 1; i <= N; i++) {if (i <= 1) {start = groupSize * (i - 1);
end = groupSize * i;
} else if (i > 1 && i < N) {start = groupSize * (i - 1) + 1;
end = groupSize * i;
} else {start = groupSize * (i - 1) + 1;
end = totalFileSize;
}
System.out.println(String.format("线程 %s 调配区间范畴 start=%s, end=%s", i, start, end));
downloadAndMerge(i, netUrl, localFilePath, start, end, countDownLatch);
}
// 校验文件一致性
countDownLatch.await();
validateCompleteness(localFilePath, netUrl);
}
5.3.5.2 文件合并
多线程 - 文件合并
/**
* 文件下载、合并
* @param threadNum 线程标识
* @param netUrl 网络文件地址
* @param localFilePath 本地文件门路
* @param start 范畴申请开始地位
* @param end 范畴申请完结地位
* @param countDownLatch 闭锁对象
*/
private static void downloadAndMerge(int threadNum, String netUrl, String localFilePath, int start, int end, CountDownLatch countDownLatch) {threadPoolExecutor.execute(() -> {
try {HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
httpURLConnection.setRequestProperty("Range", "bytes=" + start + "-" + end);
// 获取近程文件流信息
InputStream inputStream = httpURLConnection.getInputStream();
RandomAccessFile randomAccessFile = new RandomAccessFile(localFilePath, "rw");
// 文件写入开始地位指针挪动到曾经下载地位
randomAccessFile.seek(start);
byte[] buffer = new byte[1024 * 10];
int len = -1;
while ((len = inputStream.read(buffer)) != -1) {randomAccessFile.write(buffer, 0, len);
}
closeHttpUrlConnection(httpURLConnection);
System.out.println(String.format("下载实现工夫 %s, 线程:%s, 下载实现: start=%s, end = %s", System.currentTimeMillis(), threadNum, start, end));
} catch (Exception e) {System.out.println(String.format("片段下载异样:线程:%s, start=%s, end = %s", threadNum, start, end));
e.printStackTrace();}
countDownLatch.countDown();});
}
多线程下载运行后果
近程文件反对断点下载
近程文件大小:740
线程 1 调配区间范畴 start=0, end=74
线程 2 调配区间范畴 start=75, end=148
线程 3 调配区间范畴 start=149, end=222
线程 4 调配区间范畴 start=223, end=296
线程 5 调配区间范畴 start=297, end=370
线程 6 调配区间范畴 start=371, end=444
线程 7 调配区间范畴 start=445, end=518
线程 8 调配区间范畴 start=519, end=592
线程 9 调配区间范畴 start=593, end=666
线程 10 调配区间范畴 start=667, end=740
下载实现工夫 1608443874752, 线程:7, 下载实现: start=445, end = 518
下载实现工夫 1608443874757, 线程:2, 下载实现: start=75, end = 148
下载实现工夫 1608443874758, 线程:3, 下载实现: start=149, end = 222
下载实现工夫 1608443874759, 线程:5, 下载实现: start=297, end = 370
下载实现工夫 1608443874760, 线程:10, 下载实现: start=667, end = 740
下载实现工夫 1608443874760, 线程:1, 下载实现: start=0, end = 74
下载实现工夫 1608443874779, 线程:8, 下载实现: start=519, end = 592
下载实现工夫 1608443874781, 线程:6, 下载实现: start=371, end = 444
下载实现工夫 1608443874784, 线程:9, 下载实现: start=593, end = 666
下载实现工夫 1608443874788, 线程:4, 下载实现: start=223, end = 296
本地文件和近程文件统一,md5 = 1FFD36BD1B06EB6C287AF8D788458808, Etag = "1FFD36BD1B06EB6C287AF8D788458808"
从运行后果能够出,子线程下载实现工夫并没有齐全按着咱们 for 循环指定的 1 -10 线程标号程序实现,阐明子线程之间是并行在写入文件。其中还能够看到,子线程 10 和子线程 1 是在同一时间实现了文件的下载和写入,这也很好的验证了咱们下面提到的 RandomAccessFile 类的成果。
5.3.6 完整性判断
完整性校验
/**
* 校验文件一致性,咱们判断 Etag 和本地文件的 md5 是否统一
* 注:Etag 携带了双引号
* @param localFilePath
* @param netUrl
*/
private static void validateCompleteness(String localFilePath, String netUrl) throws Exception{File file = new File(localFilePath);
InputStream data = new FileInputStream(file);
String md5 = DigestUtils.md5Hex(data);
HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl);
String etag = httpURLConnection.getHeaderField("Etag");
if (etag.toUpperCase().contains(md5.toUpperCase())) {System.out.println(String.format("本地文件和近程文件统一,md5 = %s, Etag = %s", md5.toUpperCase(), etag));
} else {System.out.println(String.format("本地文件和近程文件不统一,md5 = %s, Etag = %s", md5.toUpperCase(), etag));
}
}
六、写在最初
文件断点下载的劣势在于晋升下载速度,然而也不是每种业务场景都适宜,比如说业务网络环境很好,下载的单个文件大小几十兆的状况下,应用断点下载也没有太大的劣势,反而减少了实现计划的复杂度。这就要求咱们开发人员在应用时酌情思考,而不是自觉应用。
作者:vivo-Tang Aibo