Glide-原始碼詳解

夏雨友人帳發表於2017-03-23

前言:

之前的文章中,筆者介紹了很多Glide的使用方法,但是由於Glide框架封裝得太好了,很多人在使用的時候,只是知其然不知其所以然,為了不要僅僅成為"cv工程師",只會複製貼上,所以這篇文章我們就一起來研究一下Glide的原始碼,看看Glide到底是怎麼將一張圖片載入出來的~

Glide 系列目錄

前方高能預警,本文篇幅較長,閱讀需要耐心

本文基於Glide 3.7.0版本

一.Glide的構造

//Glide.java

 Glide(Engine engine, MemoryCache memoryCache, BitmapPool bitmapPool, Context context, DecodeFormat decodeFormat) {
              ...
    }複製程式碼
Glide是通過GlideBuilder中的createGlide方法生成的(核心程式碼如下)
//GlideBuilder.java

Glide createGlide() {
        ...
        return new Glide(engine, memoryCache, bitmapPool, context, decodeFormat);
    }複製程式碼
Glide的構造引數主要有四個,都是通過createGlide生成的.
  • MemoryCache 記憶體快取

  • BitmapPool 圖片池

  • DecodeFormat 圖片格式

  • Engine 引擎類

1.MemoryCache :記憶體快取 LruResourceCache

//MemorySizeCalculator.java

final int maxSize = getMaxSize(activityManager);

private static int getMaxSize(ActivityManager activityManager) {
    //每個程式可用的最大記憶體
    final int memoryClassBytes = activityManager.getMemoryClass() * 1024 * 1024;

    //判斷是否低配手機
    final boolean isLowMemoryDevice = isLowMemoryDevice(activityManager);

    return Math.round(memoryClassBytes
            * (isLowMemoryDevice ? LOW_MEMORY_MAX_SIZE_MULTIPLIER : MAX_SIZE_MULTIPLIER));
}複製程式碼
最大記憶體:如果是低配手機,就每個程式可用的最大記憶體乘以0.33,否則就每個程式可用的最大記憶體乘以0.4
//MemorySizeCalculator.java

int screenSize = screenDimensions.getWidthPixels() * screenDimensions.getHeightPixels()
                * BYTES_PER_ARGB_8888_PIXEL;(寬*高*4)
int targetPoolSize = screenSize * BITMAP_POOL_TARGET_SCREENS;(寬*高*4*4)
int targetMemoryCacheSize = screenSize * MEMORY_CACHE_TARGET_SCREENS;(寬*高*4*2)

//判斷是否超過最大值,否則就等比縮小
if (targetMemoryCacheSize + targetPoolSize <= maxSize) {
    memoryCacheSize = targetMemoryCacheSize;
    bitmapPoolSize = targetPoolSize;
} else {
    int part = Math.round((float) maxSize / (BITMAP_POOL_TARGET_SCREENS + MEMORY_CACHE_TARGET_SCREENS));
    memoryCacheSize = part * MEMORY_CACHE_TARGET_SCREENS;
    bitmapPoolSize = part * BITMAP_POOL_TARGET_SCREENS;
}複製程式碼
targetPoolSize 和 targetMemoryCacheSize 之和不能超過maxSize 否則就等比縮小
//GlideBuilder.java

memoryCache = new LruResourceCache(calculator.getMemoryCacheSize());複製程式碼
記憶體快取用的是targetMemoryCacheSize (即一般是快取大小是螢幕的寬 4 * 2)

2.BitmapPool 圖片池 LruBitmapPool

int size = calculator.getBitmapPoolSize();
bitmapPool = new LruBitmapPool(size);複製程式碼
圖片池用的是targetPoolSize(即一般是快取大小是螢幕的寬4*4)

3.DecodeFormat 圖片格式

DecodeFormat DEFAULT = PREFER_RGB_565複製程式碼
預設是RGB_565

4.Engine 引擎類

//GlideBuilder.java

engine = new Engine(memoryCache, diskCacheFactory, diskCacheService, sourceService);複製程式碼
engine 裡面主要引數
  • 記憶體快取 memoryCache
  • 本地快取 diskCacheFactory
  • 處理源資源的執行緒池 sourceService
  • 處理本地快取的執行緒池 diskCacheService

(1)memoryCache:記憶體快取 LruBitmapPool

上面已經做了介紹

(2)diskCacheFactory:本地快取 DiskLruCacheFactory

//DiskCache.java

/** 250 MB of cache. */
        int DEFAULT_DISK_CACHE_SIZE = 250 * 1024 * 1024;
        String DEFAULT_DISK_CACHE_DIR = "image_manager_disk_cache";複製程式碼
預設大小:250 MB
預設目錄:image_manager_disk_cache

(3)sourceService 處理源資源的執行緒池 (ThreadPoolExecutor的子類)

  final int cores = Math.max(1, Runtime.getRuntime().availableProcessors());//獲得可用的處理器個數
  sourceService = new FifoPriorityThreadPoolExecutor(cores);複製程式碼
執行緒池的核心執行緒數量等於獲得可用的處理器個數

(4)diskCacheService 處理本地快取的執行緒池 (ThreadPoolExecutor的子類)

diskCacheService = new FifoPriorityThreadPoolExecutor(1);複製程式碼
執行緒池的核心執行緒數量為1

二.with方法

with方法有很多過載,最後會返回一個RequestManager
//Glide.java

/**
 * @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);
}複製程式碼

就算你傳入的是Context ,這裡也會根據你Context 實際的型別,走不同的分支
//RequestManagerRetriever.java

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);
}複製程式碼
這裡以FragmentActivity為例,最後會建立一個無介面的Fragment,即SupportRequestManagerFragment ,讓請求和你的activity的生命週期同步
//RequestManagerRetriever.java

public RequestManager get(FragmentActivity activity) {
    if (Util.isOnBackgroundThread()) {
        return get(activity.getApplicationContext());
    } else {
        assertNotDestroyed(activity);
        FragmentManager fm = activity.getSupportFragmentManager();
        return supportFragmentGet(activity, fm);
    }
}
RequestManager supportFragmentGet(Context context, FragmentManager fm) {
    SupportRequestManagerFragment current = getSupportRequestManagerFragment(fm);
    RequestManager requestManager = current.getRequestManager();
    if (requestManager == null) {
        requestManager = new RequestManager(context, current.getLifecycle(), current.getRequestManagerTreeNode());
        current.setRequestManager(requestManager);
    }
    return requestManager;
}複製程式碼
這裡需要注意一下,如果你是在子執行緒呼叫with方法,或者傳入的Context是Application的話,請求是跟你的Application的生命週期同步
//RequestManagerRetriever.java

private RequestManager getApplicationManager(Context context) {
    // Either an application context or we're on a background thread.
    if (applicationManager == null) {
        synchronized (this) {
            if (applicationManager == null) {
                // Normally pause/resume is taken care of by the fragment we add to the fragment or activity.
                // However, in this case since the manager attached to the application will not receive lifecycle
                // events, we must force the manager to start resumed using ApplicationLifecycle.
                applicationManager = new RequestManager(context.getApplicationContext(),
                        new ApplicationLifecycle(), new EmptyRequestManagerTreeNode());
            }
        }
    }
    return applicationManager;
}複製程式碼

三.load方法

這裡方法也有很多過載
//RequestManager.java

public DrawableTypeRequest<String> load(String string) {
    return (DrawableTypeRequest<String>) fromString().load(string);
}複製程式碼
但是最後都會返回一個DrawableTypeRequest (繼承了DrawableRequestBuilder)
DrawableRequestBuilder就是支援鏈式呼叫的一個類,我們平時有類似的需求的時候也可以模仿這樣的處理方式,把一些非必須引數用鏈式呼叫的方式來設定

四.into方法

//GenericRequestBuilder.java    

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) {
            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));
    }複製程式碼
這裡有三點需要注意的:
  • 1.Util.assertMainThread();這裡會檢查是否主執行緒,不是的話會丟擲異常,所以into方法必須在主執行緒中呼叫.

  • 2.當你沒有呼叫transform方法,並且你的ImageView設定了ScaleType,那麼他會根據你的設定,對圖片做處理(具體處理可以檢視DrawableRequestBuilder的applyCenterCrop或者applyFitCenter方法,我們自己自定義BitmapTransformation也可以參考這裡的處理).

  • 3.view在這裡被封裝成一個Target.

      //GenericRequestBuilder.java        
    
      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())");
          }
          Request previous = target.getRequest();
          if (previous != null) {
              previous.clear();
              requestTracker.removeRequest(previous);
              previous.recycle();
          }
          Request request = buildRequest(target);
          target.setRequest(request);
          lifecycle.addListener(target);
          requestTracker.runRequest(request);
          return target;
      }複製程式碼
這裡可以看到控制元件封裝成的Target能夠獲取自身繫結的請求,當發現之前的請求還在的時候,會把舊的請求清除掉,繫結新的請求,這也就是為什麼控制元件複用時不會出現圖片錯位的問題(這點跟我在Picasso原始碼中看到的處理方式很相像).
接著在into裡面會呼叫buildRequest方法來建立請求
//GenericRequestBuilder.java    

private Request buildRequest(Target<TranscodeType> target) {
        if (priority == null) {
            priority = Priority.NORMAL;
        }
        return buildRequestRecursive(target, null);
    }複製程式碼

//GenericRequestBuilder.java

private Request buildRequestRecursive(Target<TranscodeType> target, ThumbnailRequestCoordinator parentCoordinator) {
        if (thumbnailRequestBuilder != null) {
           ...
            Request fullRequest = obtainRequest(target, sizeMultiplier, priority, coordinator);
           ...
            Request thumbRequest = thumbnailRequestBuilder.buildRequestRecursive(target, coordinator);
           ...
            coordinator.setRequests(fullRequest, thumbRequest);
            return coordinator;
        } else if (thumbSizeMultiplier != null) {           
            ThumbnailRequestCoordinator coordinator = new ThumbnailRequestCoordinator(parentCoordinator);
            Request fullRequest = obtainRequest(target, sizeMultiplier, priority, coordinator);
            Request thumbnailRequest = obtainRequest(target, thumbSizeMultiplier, getThumbnailPriority(), coordinator);
            coordinator.setRequests(fullRequest, thumbnailRequest);
            return coordinator;
        } else {
            // Base case: no thumbnail.
            return obtainRequest(target, sizeMultiplier, priority, parentCoordinator);
        }
    }複製程式碼
1.這裡就是請求的生成,buildRequestRecursive裡面if有三個分支,這裡是根據你設定thumbnail的情況來判斷的,第一個是設定縮圖為新的請求的情況,第二個是設定縮圖為float的情況,第三個就是沒有設定縮圖的情況.
前兩個設定了縮圖的是有兩個請求的,fullRequest和thumbnailRequest,沒有設定縮圖則肯定只有一個請求了.
2.請求都是通過obtainRequest方法生成的(這個簡單瞭解一下就行)
//GenericRequestBuilder.java

private Request obtainRequest(Target<TranscodeType> target, float sizeMultiplier, Priority priority,
            RequestCoordinator requestCoordinator) {
        return GenericRequest.obtain(...);
    }複製程式碼
REQUEST_POOL是一個佇列,當佇列中有,那麼就從佇列中取,沒有的話就新建一個GenericRequest
//GenericRequest.java

public static <A, T, Z, R> GenericRequest<A, T, Z, R> obtain(...) {

        GenericRequest<A, T, Z, R> request = (GenericRequest<A, T, Z, R>) REQUEST_POOL.poll();
        if (request == null) {
            request = new GenericRequest<A, T, Z, R>();
        }
        request.init(...);
        return request;
    }複製程式碼
回到into方法:當建立了請求後runRequest會呼叫Request的begin方法,即呼叫GenericRequest的begin方法
//GenericRequestBuilder.java

 public <Y extends Target<TranscodeType>> Y into(Y target) {

        Request request = buildRequest(target);
      ...
        requestTracker.runRequest(request);
      ...
    }複製程式碼

//GenericRequest.java

 public void begin() {
    ...
      if (Util.isValidDimensions(overrideWidth, overrideHeight)) {
            onSizeReady(overrideWidth, overrideHeight);
        } else {
            target.getSize(this);
        }
    ...
    }複製程式碼
最終會呼叫Engine的load方法
//GenericRequest.java

public void onSizeReady(int width, int height) {
    ...
    loadStatus = engine.load(signature, width, height, dataFetcher, loadProvider, transformation, transcoder,
                priority, isMemoryCacheable, diskCacheStrategy, this);
    ...
    }複製程式碼
我們先看load方法的前面一段:
1.首先會嘗試從cache裡面取,這裡cache就是Glide的建構函式裡面的MemoryCache(是一個LruResourceCache),如果取到了,就從cache裡面刪掉,然後加入activeResources中
2.如果cache裡面沒取到,就會從activeResources中取,activeResources是一個以弱引用為值的map,他是用於儲存使用中的資源.之所以在記憶體快取的基礎上又多了這層快取,是為了當記憶體不足而清除cache中的資源中,不會影響使用中的資源.
//Engine.java    

public <T, Z, R> LoadStatus load(...) {
        ...
        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;
        }
        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;
        }
        ...
    }複製程式碼
load方法接著會通過EngineJobFactory建立一個EngineJob,裡面主要管理裡兩個執行緒池,diskCacheService和sourceService,他們就是Glide建構函式中Engine裡面建立的那兩個執行緒池.
//Engine.java

public <T, Z, R> LoadStatus load(...) {
            ...
            EngineJob engineJob = engineJobFactory.build(key, isMemoryCacheable);
           ...
        }複製程式碼

//Engine.java
static class EngineJobFactory {
    private final ExecutorService diskCacheService;
    private final ExecutorService sourceService;
    private final EngineJobListener listener;

    public EngineJobFactory(ExecutorService diskCacheService, ExecutorService sourceService,
            EngineJobListener listener) {
        this.diskCacheService = diskCacheService;
        this.sourceService = sourceService;
        this.listener = listener;
    }

    public EngineJob build(Key key, boolean isMemoryCacheable) {
        return new EngineJob(key, diskCacheService, sourceService, isMemoryCacheable, listener);
    }
}複製程式碼
接著說load方法,前面建立了EngineJob,接著呼叫EngineJob的start方法,並將EngineRunnable放到diskCacheService(處理磁碟快取的執行緒池裡面執行),接著執行緒池就會呼叫EngineRunnable的run方法.
//Engine.java

public <T, Z, R> LoadStatus load(...) {
        ...
        EngineRunnable runnable = new EngineRunnable(engineJob, decodeJob, priority);
        jobs.put(key, engineJob);
        engineJob.addCallback(cb);
        engineJob.start(runnable);
       ...
    }複製程式碼

//EngineJob.java

public void start(EngineRunnable engineRunnable) {
    this.engineRunnable = engineRunnable;
    future = diskCacheService.submit(engineRunnable);
}複製程式碼

//EngineRunnable.java

public void run() {
       ...
        try {
            resource = decode();
        } catch (Exception e) {
            if (Log.isLoggable(TAG, Log.VERBOSE)) {
                Log.v(TAG, "Exception decoding", e);
            }
            exception = e;
        }
       ...
        if (resource == null) {
            onLoadFailed(exception);
        } else {
            onLoadComplete(resource);
        }
    }複製程式碼
run裡面呼叫的是decode()方法,裡面會嘗試先從磁碟快取中讀取,如果不行就從源資源中讀取
//EngineRunnable.java    

private Resource<?> decode() throws Exception {
    if (isDecodingFromCache()) {
        //第一次會走這
        return decodeFromCache();//從磁碟快取中讀取

    } else {

        return decodeFromSource();//從源資源中讀取

    }
}複製程式碼
我們先來看從磁碟中讀取的策略
//EngineRunnable.java

private Resource<?> decodeFromCache() throws Exception {
    Resource<?> result = null;
    try {
        result = decodeJob.decodeResultFromCache();
    } catch (Exception e) {
        if (Log.isLoggable(TAG, Log.DEBUG)) {
            Log.d(TAG, "Exception decoding result from cache: " + e);
        }
    }

    if (result == null) {
        result = decodeJob.decodeSourceFromCache();
    }
    return result;
}複製程式碼
我們可以看到這裡先嚐試讀取處理後的圖片(Result),然後再嘗試讀取原圖,但是這裡面具體邏輯會根據你設定的磁碟快取策略來決定是否真的會讀取處理圖和原圖
那麼我們再回到EngineRunnable的run()方法中
public void run() {
           ...
            try {
                resource = decode();
            } catch (Exception e) {
                if (Log.isLoggable(TAG, Log.VERBOSE)) {
                    Log.v(TAG, "Exception decoding", e);
                }
                exception = e;
            }
           ...
            if (resource == null) {
                onLoadFailed(exception);
            } else {
                onLoadComplete(resource);
            }
        }複製程式碼
第一次走decode的時候會先嚐試從磁碟中獲取,如果獲取的為null,那麼在onLoadFailed方法裡面又會把這個run再次放入執行緒池中,但是這次是放入sourceService(處理源資源的執行緒池)
//EngineRunnable.java

private void onLoadFailed(Exception e) {
    if (isDecodingFromCache()) {
        stage = Stage.SOURCE;
        manager.submitForSource(this);
    } else {
        manager.onException(e);
    }
}複製程式碼

//EngineJob.java

@Override
public void submitForSource(EngineRunnable runnable) {
    future = sourceService.submit(runnable);
}複製程式碼
接著sourceService裡面又會呼叫呼叫EngineRunnable的run方法,這次decode裡面會走從源資源讀取的那條分支
//EngineRunnable.java    

private Resource<?> decode() throws Exception {
    if (isDecodingFromCache()) {
        //第一次會走這
        return decodeFromCache();//從磁碟快取中讀取

    } else {
        //第二次會走這
        return decodeFromSource();//從源資源讀取

    }
}

//DecodeJob.java

public Resource<Z> decodeFromSource() throws Exception {
    Resource<T> decoded = decodeSource();//獲取資料,並解碼
    return transformEncodeAndTranscode(decoded);//處理圖片
}複製程式碼
裡面主要做了兩件事,一個是獲取圖片,一個是處理圖片
1.我們先來看獲取圖片的decodeSource方法
//DecodeJob.java

private Resource<T> decodeSource() throws Exception {
         ...
        //拉取資料
        final A data = fetcher.loadData(priority);
       ...
        //解碼,並儲存源資源到磁碟
        decoded = decodeFromSourceData(data);
          ...
    return decoded;
}複製程式碼

//DecodeJob.java
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;
}複製程式碼
這裡呼叫了DataFetcher的loadData方法來獲取資料,DataFetcher有很多實現類,一般來說我們都是從網路中讀取資料,我們這邊就以HttpUrlFetcher為例
//HttpUrlFetcher.java

@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.
        }
    }
    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) {
        //請求成功
        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());
    }
}複製程式碼
2.看完了獲取圖片的方法,我們再來看看處理圖片的方法transformEncodeAndTranscode
//DecodeJob.java

private Resource<Z> transformEncodeAndTranscode(Resource<T> decoded) {
   ...
    //對圖片做剪裁等處理
    Resource<T> transformed = transform(decoded);
  ...
    //將處理後的圖片寫入磁碟快取(會根據配置來決定是否寫入)
    writeTransformedToCache(transformed);
 ...
    //轉碼,轉為需要的型別
    Resource<Z> result = transcode(transformed);
 ...
    return result;
}複製程式碼

Glide的整個載入流程就基本上走完了,整個篇幅還是比較長的,第一次看得時候可能會有點懵逼,需要結合著原始碼多走幾遍才能夠更加熟悉.

閱讀原始碼並不是我們最終的目的,我們閱讀原始碼主要有兩個目的

一個是能夠更加熟悉這個框架,那麼使用的時候就更加得心應手了,比如我從原始碼中發現了很多文章都說錯了預設的快取策略
一個是學習裡面的設計模式和思想,應用到我們自己的專案中,比如說面對介面程式設計,對於不同的資料,用DataFetcher的不同實現類來拉取資料

熱門文章

相關文章