工作流程:
既然Glide的功能这么强大,那么就一定要学习下源码,看看内部是怎样工作的。
Glide.with((Fragment) t).load(url).into(imageView);
为了好理解,将上面一行代码进行分解:
RequestManager requestManager = Glide.with((Fragment) t);
DrawableTypeRequest drawableTypeRequest = requestManager.load(url);
//class DrawableTypeRequest<ModelType> extends DrawableRequestBuilder<ModelType>
Target<GlideDrawable> target = DrawableRequestBuilder.into(imageView);
这几个类,我们一起了解下。
Glide:单例,提供使用(BitmapRequestBuilder)构建请求、包含engine,BitmapPool,DiskCache,MemoryCache的简单的静态方法。
Engine:启动下载和管理当前正在使用或者缓存的资源。
BitmapPool:接口类型,允许用户重用bitmap 对象。实现类 LruBitmapPool :复用bitmap,使用最近最少使用策略保证池子的大小。
RequestManager:用于管理和启动Glide请求的类,可以使用activity,fragment连接其生命周期方法来智能的中止、启动和重新发起请求。通过实例化一个新对象,或者利用Activity和Fragment的生命周期处理,在Fragment或者Activity中,使用Glide的静态load方法。
DrawableTypeRequest : 创建一个下载请求来下载drawable或者Bitmap drawable。
Target:接口类型,将加载的资源填充到target对象身上。实现类 ImageViewTarget ViewTarget GlideDrawableImageViewTarget等等。
那就从Glide的with()方法开始看。with方法可以接收context、activity、fragment类型。
/**
* Begin a load with Glide by passing in a context.
* 通过传递一个上下文对象来开始Glide。
* 传入的如果是fragment/Activity,那么这个请求的生命周期就跟fragment/Activity生命周期一样
* 如果是上下文是application,那么请求的声明周期就是应用级别的。
* @see #with(android.app.Activity)
* @see #with(android.app.Fragment)
* @see #with(android.support.v4.app.Fragment)
* @see #with(android.support.v4.app.FragmentActivity)
*
* @param context Any context, will not be retained.
* @return A RequestManager for the top level application that can be used to start a load.
*/
public static RequestManager with(Context context) {
RequestManagerRetriever retriever = RequestManagerRetriever.get();
return retriever.get(context);
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public static RequestManager with(Activity activity) {
RequestManagerRetriever retriever = RequestManagerRetriever.get();
return retriever.get(activity);
}
public static RequestManager with(Fragment fragment) {
RequestManagerRetriever retriever = RequestManagerRetriever.get();
return retriever.get(fragment);
}
如果传入的是Activity或者Fragment,那么是怎么保证生命周期的?点进去看一下
public RequestManager get(Context context) {
if (context == null) {
throw new IllegalArgumentException("You cannot start a load on a null Context");
} else if (Util.isOnMainThread() && !(context instanceof Application)) {
if (context instanceof FragmentActivity) {
return get((FragmentActivity) context);
} else if (context instanceof Activity) {
return get((Activity) context);
} else if (context instanceof ContextWrapper) {
return get(((ContextWrapper) context).getBaseContext());
}
}
return getApplicationManager(context);
}
先判断如果不是在主线程,或者是application类型那么直接return getApplicationManager(context); 以activity类型为例,那么会调到下面的方法。
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public RequestManager get(Activity activity) {
if (Util.isOnBackgroundThread() || Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) {
return get(activity.getApplicationContext());
} else {
assertNotDestroyed(activity);
android.app.FragmentManager fm = activity.getFragmentManager();
return fragmentGet(activity, fm);
}
}
如果是后台线程的话,继续创建 application级别的 requestmanager则会执行到fragmentGet(activity, fm);
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
RequestManager fragmentGet(Context context, android.app.FragmentManager fm) {
RequestManagerFragment current = getRequestManagerFragment(fm);
RequestManager requestManager = current.getRequestManager();
if (requestManager == null) {
requestManager = new RequestManager(context, current.getLifecycle(), current.getRequestManagerTreeNode());
current.setRequestManager(requestManager);
}
return requestManager;
}
方法内部会创建一个RequestManagerFragment对象 current,是一个fragment对象。
requestManager = new RequestManager(context, current.getLifecycle(), current.getRequestManagerTreeNode());
将fragment的ActivityFragmentLifecycle 给requestManager。这样在RequestManager内部,通过 添加一个
addListener(LifecycleListener listener)
就可以保持与fragment的生命周期保持同步。
/**
* 接口监听Fragment和Activity的生命周期事件。
*/
public interface LifecycleListener {
/**
*当Activity或者fragment的onStart方法执行的时候调用。
*/
void onStart();
/**
* 当Activity或者fragment的onStop方法执行的时候调用。
*/
void onStop();
/**
*当Activity或者fragment的onDestroy方法执行的时候调用。
*/
void onDestroy();
}
在RequestManagManager中
/**
*重新开始失败或者暂停的requests.
*/
@Override
public void onStart() {
resumeRequests();
}
/**
* 停止request
*/
@Override
public void onStop() {
pauseRequests();
}
/**
*取消请求,清楚回收资源
*/
@Override
public void onDestroy() {
requestTracker.clearRequests();
}
以上过程保证了Glide发起的request,与Activity/Fragment的生命周期保持一致,当Activity/Fragment销毁时,取消请求,这样能及时回收资源。
接下来再看下load方法。
/**
* 返回一个来下载drawable或者Bitmap drawable的request对象。
*
* @see #fromString()
* @see #load(Object)
*
* @param string A file path, or a uri or url handled by {@link com.bumptech.glide.load.model.UriLoader}.
*/
public DrawableTypeRequest<String> load(String string) {
return (DrawableTypeRequest<String>) fromString().load(string);
}
在我们介绍Glide 的功能时,设置的占位图,加载失败展示图,动画效果等都是由DrawableRequestBuilder来完成的。并且包含了具体的发起请求步骤。
Glide.with((Activity) t).load(url).placeholder(holderId).error(errorId).dontAnimate().dontTransform().into(imageView);
继承自GenericRequestBuilder,它是一个泛型类,可以处理设置选项和初始加载。可以看下它的成员变量
public class GenericRequestBuilder<ModelType, DataType, ResourceType, TranscodeType> implements Cloneable {
protected final Class<ModelType> modelClass;
protected final Context context;
protected final Glide glide;
protected final Class<TranscodeType> transcodeClass;
//用于跟踪、取消和重新启动正在进行的、已完成的和失败的请求的类。
protected final RequestTracker requestTracker;
//生命周期
protected final Lifecycle lifecycle;
//磁盘缓存策略,只保存处理后的结果。
private DiskCacheStrategy diskCacheStrategy = DiskCacheStrategy.RESULT;
//设置的transformation
private Transformation<ResourceType> transformation = UnitTransformation.get();
//占位图id
private int placeholderId;
//失败图id
private int errorId;
...
以上通过load()方法,将网络,或者本地的资源,封装成一个GenericRequestBuilder 实例。
接下来是最后一个方法into(), 是绑定target对象。将前面要展示的资源,与要显示的载体绑定。这一步也是不可或缺的,一起分析下绑定流程。
@Override
public Target<GlideDrawable> into(ImageView view) {
return super.into(view);
}
直接看下父类是怎么生成target对象的。
/**
* 设置资源加载的ImageView,会取消一切当前存在的加载,释放任何先前已经加载到View上的资源,以便可以复用。
* @see Glide#clear(android.view.View)
*
* @param view 取消view身上之前设置的的下载,加载新的下载。
* @return Target 是传入的view的封装。
*/
public Target<TranscodeType> into(ImageView view) {
Util.assertMainThread();
if (view == null) {
throw new IllegalArgumentException("You must pass in a non null View");
}
if (!isTransformationSet && view.getScaleType() != null) {
//如果设置过ImageView的scaleType
switch (view.getScaleType()) {
case CENTER_CROP:
applyCenterCrop();
break;
case FIT_CENTER:
case FIT_START:
case FIT_END:
applyFitCenter();
break;
//$CASES-OMITTED$
default:
// Do nothing.
}
}
return into(glide.buildImageViewTarget(view, transcodeClass));
}
执行到最下面一行,继续跟进去。
return into(glide.buildImageViewTarget(view, transcodeClass));
glide.buildImageViewTarget(view, transcodeClass) 对View做了一些封装,比如 在开始下载的时候,先展示默认图。这里就不一起看了,可以自己点进去看看,继续主流程。
/**
* 设置target要展示的图片资源。
*/
public <Y extends Target<TranscodeType>> Y into(Y target) {
Util.assertMainThread();
if (target == null) {
throw new IllegalArgumentException("You must pass in a non null Target");
}
if (!isModelSet) {
throw new IllegalArgumentException("You must first set a model (try #load())");
}
//先判断当前target有没有被设置过request
Request previous = target.getRequest();
if (previous != null) {
//复用的target,释放资源。
previous.clear();
requestTracker.removeRequest(previous);
previous.recycle();
}
//给target重新设置request
Request request = buildRequest(target);
target.setRequest(request);
//添加声明周期方法,保证fragment声明周期回调时,request做出相应的处理。
lifecycle.addListener(target);
//开启请求
requestTracker.runRequest(request);
return target;
}
这里要注意,对复用类型的 target首先判断之前有没有设置过request。设置的方式实际上是通过view.setTag()来实现的。
因此,在我们使用Glide的时候, 要注意,不要自己调用 view.setTag() 否则会报错 。
java.lang.IllegalArgumentException: You must not call setTag() on a view Glide is targeting
接下来就重点看下请求怎么发起的,request的发起,暂停,移除,恢复request都由RequestTrackerjia 间接管理。
public void runRequest(Request request) {
requests.add(request);
if (!isPaused) {
request.begin();
} else {
pendingRequests.add(request);
}
}
实际上调用的就是request.begin()
@Override
public void begin() {
startTime = LogTime.getLogTime();
if (model == null) {
onException(null);
return;
}
status = Status.WAITING_FOR_SIZE;
if (Util.isValidDimensions(overrideWidth, overrideHeight)) {
//指定过宽度,长度。
onSizeReady(overrideWidth, overrideHeight);
} else {
//先获取长度
target.getSize(this);
}
//未下载完成,展示占位图。
if (!isComplete() && !isFailed() && canNotifyStatusChanged()) {
target.onLoadStarted(getPlaceholderDrawable());
}
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logV("finished run method in " + LogTime.getElapsedMillis(startTime));
}
}
不论是重新指定过宽高还是使用target的默认宽高,最终都会回调到onSizeReady。
/**
* 回调方法,永远不能直接调用。
*/
@Override
public void onSizeReady(int width, int height) {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logV("Got onSizeReady in " + LogTime.getElapsedMillis(startTime));
}
if (status != Status.WAITING_FOR_SIZE) {
return;
}
status = Status.RUNNING;
width = Math.round(sizeMultiplier * width);
height = Math.round(sizeMultiplier * height);
ModelLoader<A, T> modelLoader = loadProvider.getModelLoader();
final DataFetcher<T> dataFetcher = modelLoader.getResourceFetcher(model, width, height);
if (dataFetcher == null) {
onException(new Exception("Failed to load model: \'" + model + "\'"));
return;
}
ResourceTranscoder<Z, R> transcoder = loadProvider.getTranscoder();
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logV("finished setup for calling load in " + LogTime.getElapsedMillis(startTime));
}
loadedFromMemoryCache = true;
//交给Engine去获取资源
loadStatus = engine.load(signature, width, height, dataFetcher, loadProvider, transformation, transcoder,
priority, isMemoryCacheable, diskCacheStrategy, this);
loadedFromMemoryCache = resource != null;
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logV("finished onSizeReady in " + LogTime.getElapsedMillis(startTime));
}
}
接下来执行到engine.laod() 方法。这个方法是根据传递的参数去开启资源加载。
流程如下:
1.从memorycache中检索资源,如果内存中有缓存资源,使用内存中的缓存。
2.检索当前使用的活跃资源(ActiveResources),如果检索到。使用当前活跃的。
ActiveResources也是内存缓存策略中的,只要是正在使用的缓存,都是加入到ActiveResource中的,ActiveResource核心是维护了一个弱引用的Map来储存对应的缓存数据
3.从当前正在进行加载的任务集合中查找,如果前面已经添加进加载队列,那么直接使用。
4.开始一个新的下载。
内存缓存策略:使用了两级内存缓存,MemoryCache和ActiveResource,前者默认为一个LruResourceCache,
后者是一个Map弱引用,引用了从MemoryCache中读取过的资源和从网络、硬盘下载和转换出的资源。
加载图片时先使用MemoryCache,如果没有找到则尝试从ActiveResource中获取资源。如果还是没有再从磁盘、网络获取资源。
/**
* Starts a load for the given arguments. Must be called on the main thread.
*根据给定的参数去开启资源加载,这个方法工作在主线程。
*/
public <T, Z, R> LoadStatus load(Key signature, int width, int height, DataFetcher<T> fetcher,
DataLoadProvider<T, Z> loadProvider, Transformation<Z> transformation, ResourceTranscoder<Z, R> transcoder,
Priority priority, boolean isMemoryCacheable, DiskCacheStrategy diskCacheStrategy, ResourceCallback cb) {
Util.assertMainThread();
long startTime = LogTime.getLogTime();
final String id = fetcher.getId();
//根据签名、宽高、transformation等等信息对要加载的资源生成一个唯一的key。
EngineKey key = keyFactory.buildKey(id, signature, width, height, loadProvider.getCacheDecoder(),
loadProvider.getSourceDecoder(), transformation, loadProvider.getEncoder(),
transcoder, loadProvider.getSourceEncoder());
//1.从内存中获取,isMemoryCacheable:是否允许内存缓存策略
EngineResource<?> cached = loadFromCache(key, isMemoryCacheable);
if (cached != null) {
cb.onResourceReady(cached);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Loaded resource from cache", startTime, key);
}
return null;
}
//2.从activeResources中获取 isMemoryCacheable:是否允许内存缓存策略
EngineResource<?> active = loadFromActiveResources(key, isMemoryCacheable);
if (active != null) {
cb.onResourceReady(active);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Loaded resource from active resources", startTime, key);
}
return null;
}
//3.是否之前已经添加了该任务。
EngineJob current = jobs.get(key);
if (current != null) {
current.addCallback(cb);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Added to existing load", startTime, key);
}
return new LoadStatus(cb, current);
}
//4.执行到这里,说明要新建任务了。engineJobFactory.build()
EngineJob engineJob = engineJobFactory.build(key, isMemoryCacheable);
DecodeJob<T, Z, R> decodeJob = new DecodeJob<T, Z, R>(key, width, height, fetcher, loadProvider, transformation,
transcoder, diskCacheProvider, diskCacheStrategy, priority);
//将任务添加到集合中。 Map<Key, EngineJob> jobs
jobs.put(key, engineJob);
engineJob.addCallback(cb);
//启动任务start();
engineJob.start(runnable);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Started new load", startTime, key);
}
return new LoadStatus(cb, engineJob);
}
/**
*1.从内存中获取。
*/
private EngineResource<?> loadFromCache(Key key, boolean isMemoryCacheable) {
//不允许内存缓存 直接返回
if (!isMemoryCacheable) {
return null;
}
//尝试从内存中获取
EngineResource<?> cached = getEngineResourceFromCache(key);
if (cached != null) {
//从内存中获取到
cached.acquire();
//加入到activeResources集合。activeResources = new HashMap<Key, WeakReference<EngineResource<?>>>();
activeResources.put(key, new ResourceWeakReference(key, cached, getReferenceQueue()));
}
return cached;
}
/**
*2.从activeResources中获取。
*/
private EngineResource<?> loadFromActiveResources(Key key, boolean isMemoryCacheable) {
//不允许内存缓存 直接返回
if (!isMemoryCacheable) {
return null;
}
EngineResource<?> active = null;
WeakReference<EngineResource<?>> activeRef = activeResources.get(key);
if (activeRef != null) {
//从weakreference中 获取
active = activeRef.get();
if (active != null) {
//没有被回收。
active.acquire();
} else {
activeResources.remove(key);
}
}
return active;
}
如果缓存中没有,接下来会创建下载任务,开启任务。engineJobFactory.build()创建1个 EngineJob。然后启动
EngineJob engineJob = engineJobFactory.build(key, isMemoryCacheable);
DecodeJob<T, Z, R> decodeJob = new DecodeJob<T, Z, R>(key, width, height, fetcher, loadProvider, transformation,
transcoder, diskCacheProvider, diskCacheStrategy, priority);
EngineRunnable runnable = new EngineRunnable(engineJob, decodeJob, priority);
jobs.put(key, engineJob);
engineJob.addCallback(cb);
engineJob.start(runnable);
public void start(EngineRunnable engineRunnable) {
this.engineRunnable = engineRunnable;
future = diskCacheService.submit(engineRunnable);
}
磁盘线程通过submit提交,后最终执行EngineRunnable 的run方法
@Override
public void run() {
if (isCancelled) {
return;
}
Exception exception = null;
Resource<?> resource = null;
try {
//获取资源
resource = decode();
} catch (Exception e) {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Exception decoding", e);
}
exception = e;
}
//如果已经取消任务,回收资源。
if (isCancelled) {
if (resource != null) {
resource.recycle();
}
return;
}
//拿到资源后回调
if (resource == null) {
onLoadFailed(exception);
} else {
onLoadComplete(resource);
}
}
主要看下怎么获取资源的,即 decode()方法
private Resource<?> decode() throws Exception {
if (isDecodingFromCache()) {
//从缓存中
return decodeFromCache();
} else {
//从数据源中
return decodeFromSource();
}
}
这里是从数据源获取,我们直接看decodeFromSource();
private Resource<?> decodeFromSource() throws Exception {
return decodeJob.decodeFromSource();
}
/**
*对返回的resource 添加transformed 。
* @throws Exception
*/
public Resource<Z> decodeFromSource() throws Exception {
Resource<T> decoded = decodeSource();
return transformEncodeAndTranscode(decoded);
}
private Resource<T> decodeSource() throws Exception {
Resource<T> decoded = null;
try {
long startTime = LogTime.getLogTime();
//请求资源。
final A data = fetcher.loadData(priority);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Fetched data", startTime);
}
if (isCancelled) {
return null;
}
//
decoded = decodeFromSourceData(data);
} finally {
fetcher.cleanup();
}
return decoded;
}
@Override
public InputStream loadData(Priority priority) throws Exception {
return loadDataWithRedirects(glideUrl.toURL(), 0 /*redirects*/, null /*lastUrl*/, glideUrl.getHeaders());
}
private InputStream loadDataWithRedirects(URL url, int redirects, URL lastUrl, Map<String, String> headers)
throws IOException {
if (redirects >= MAXIMUM_REDIRECTS) {
throw new IOException("Too many (> " + MAXIMUM_REDIRECTS + ") redirects!");
} else {
// Comparing the URLs using .equals performs additional network I/O and is generally broken.
// See http://michaelscharf.blogspot.com/2006/11/javaneturlequals-and-hashcode-make.html.
try {
if (lastUrl != null && url.toURI().equals(lastUrl.toURI())) {
throw new IOException("In re-direct loop");
}
} catch (URISyntaxException e) {
// Do nothing, this is best effort.
}
}
//使用的是HttpUrlConnection
urlConnection = connectionFactory.build(url);
for (Map.Entry<String, String> headerEntry : headers.entrySet()) {
urlConnection.addRequestProperty(headerEntry.getKey(), headerEntry.getValue());
}
//连接时间,超时时间。
urlConnection.setConnectTimeout(2500);
urlConnection.setReadTimeout(2500);
urlConnection.setUseCaches(false);
urlConnection.setDoInput(true);
// Connect explicitly to avoid errors in decoders if connection fails.
urlConnection.connect();
if (isCancelled) {
return null;
}
final int statusCode = urlConnection.getResponseCode();
if (statusCode / 100 == 2) {
//200 请求成功
return getStreamForSuccessfulRequest(urlConnection);
} else if (statusCode / 100 == 3) {
String redirectUrlString = urlConnection.getHeaderField("Location");
if (TextUtils.isEmpty(redirectUrlString)) {
throw new IOException("Received empty or null redirect url");
}
URL redirectUrl = new URL(url, redirectUrlString);
return loadDataWithRedirects(redirectUrl, redirects + 1, url, headers);
} else {
if (statusCode == -1) {
throw new IOException("Unable to retrieve response code from HttpUrlConnection.");
}
throw new IOException("Request failed " + statusCode + ": " + urlConnection.getResponseMessage());
}
}
//返回inputStream
private InputStream getStreamForSuccessfulRequest(HttpURLConnection urlConnection)
throws IOException {
if (TextUtils.isEmpty(urlConnection.getContentEncoding())) {
int contentLength = urlConnection.getContentLength();
stream = ContentLengthInputStream.obtain(urlConnection.getInputStream(), contentLength);
} else {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Got non empty content encoding: " + urlConnection.getContentEncoding());
}
stream = urlConnection.getInputStream();
}
return stream;
}
//对InputStream封装成 Bitmap资源。
private Resource<T> decodeFromSourceData(A data) throws IOException {
final Resource<T> decoded;
if (diskCacheStrategy.cacheSource()) {
//缓存源数据
decoded = cacheAndDecodeSourceData(data);
} else {
long startTime = LogTime.getLogTime();
//包装数据。
decoded = loadProvider.getSourceDecoder().decode(data, width, height);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Decoded from source", startTime);
}
}
return decoded;
}
再回到前面的decodeFromSource()方法,将 resource 进行缓存。
private Resource<Z> transformEncodeAndTranscode(Resource<T> decoded) {
long startTime = LogTime.getLogTime();
Resource<T> transformed = transform(decoded);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Transformed resource from source", startTime);
}
writeTransformedToCache(transformed);
startTime = LogTime.getLogTime();
Resource<Z> result = transcode(transformed);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logWithTimeAndKey("Transcoded transformed from source", startTime);
}
return result;
}
这样,从发起request到获取到resource的流程就完成了。整个流程就是对最开始一张图的分析。
再来总结一下:
1.with(context):传入上下文对象,Glide内部 根据上下文的类型,来创建RequestManager。request的生命周期,受传入的context参数影响,如果传递的是application类型的,那么request的生命周期跟随应用走,如果是activity,或者fragment,那么生命周期与传入activity,或者fragment的生命周期保持一致。如果Activity/Fragment 被销毁,request 也会被cancel。
2.load(url): 根据url资源封装一个来返回drawable或者Bitmap drawable的request对象。
3.into(target):传入要承载resource 的target对象。先判断target有没有被复用过,如果复用过,先回收target身上设置过的request info,然后重新设置新的request info.设置的方式是通过view.setTag()所以,使用Glide加载图片的时候,我们不要自己调用setTag()方法,否则会报错。java.lang.IllegalArgumentException: You must not call setTag() on a view Glide is targeting。
设置完request info 后,会启动request,获取resource,获取的策略优先级是memorycache->activeRearource->diskcache-> netWork。
或许你还有疑问,后面会继续看以下几个问题。
Glide 中线程池用大小?
Bitmap如何复用的? Bitmappool。
发起request 拿到的 resource 如何缓存的。?
Glide缓存机制 内存 磁盘?
Glide中的设计模式?
https://muyangmin.github.io/glide-docs-cn/
http://www.paincker.com/glide-study
https://blog.csdn.net/u012124438/article/details/73612492
https://www.cnblogs.com/android-blogs/p/5735655.html
https://blog.csdn.net/guolin_blog/article/details/53939176
https://segmentfault.com/a/1190000014853308
谢谢认真观读本文的每一位小伙伴,衷心欢迎小伙伴给我指出文中的错误,也欢迎小伙伴与我交流学习。
欢迎爱学习的小伙伴加群一起进步:230274309 。