深入理解 Android 之 Picasso 源码解析

分析一个图片加载库的原理,选择代码量不多且比较容易分析的Picasso来进行源码分析与理解。

基本使用

下面这行代码就是它最基本的使用啦:

Picasso.with(context).load(url).into(imageView);

当然也可以对它进行基本的图片转换操作了

Picasso.with(context)
  .load(url)
  // 裁剪图片尺寸
  .resize(100, 100)
  // 设置图片圆角
  .centerCrop()
  .into(imageView)

占位图替代和加载失败之后所作出的操作

Picasso.with(context)
    .load(url)
    // 加载过程中的占位图
    .placeholder(R.drawable.user_placeholder)
    // 如果重试3次还是无法成功加载图片,则用错误占位符图片显示。
    .error(R.drawable.user_placeholder_error)
    .into(imageView);

从不同地方加载图片

// 加载资源文件
Picasso.with(context).load(R.drawable.landing_screen).into(imageView1);
// 加载本地文件
Picasso.with(context).load(new File("/images/oprah_bees.gif")).into(imageView2);

给图片设置tag

// 加载图片并设置tag,可以通过tag来暂定或者继续加载,可以用于当ListView滚动是暂定加载.停止滚动恢复加载.
Picasso.with(this).load("url").tag(mContext).into(imageView);
Picasso.with(this).pauseTag(mContext);
Picasso.with(this).resumeTag(mContxt);

更多的特性,如Debug模式可以参见其官网介绍啦,其实也就是API的使用,了解一个框架原理之前,最好能熟知它的用法。

特性优势

  • 能够根据不同网络情况,修改线程池ExecutorService的线程个数,4g、3g、wifi情况下表现不同
  • 创建默认的监控器,统计下载时长、缓存命中率等
  • 通过BitmapHunter这个核心类中的run方法来下载图片,并将图片解码成Bitmap,然后做一些转换操作,剪裁啊,旋转啊等
  • 显示图片加载来源,设置不同的tag
  • 轻量级、易扩展

框架概览

  • 请求分发模块。负责封装请求,对请求进行优先级排序,并按照类型进行分发。

  • 缓存模块。通常包括一个二级的缓存,内存缓存、磁盘缓存。并预置多种缓存策略。

  • 下载模块。负责下载网络图片。

  • 监控模块。负责监控缓存命中率、内存占用、加载图片平均耗时等。

  • 图片处理模块。负责对图片进行压缩、变换等处理。

  • 本地资源加载模块。负责加载本地资源,如assert、drawable、sdcard等。

  • 显示模块。负责将图片输出显示。

步骤框架

下图来自网图codekk源码分析系列,特此声明:

整个执行步骤

创建->入队->执行->解码->变换->批处理->完成->分发->显示

深入源码

接下来就重点分析Picasso的源码了,分析源码得找好切入点,细节太多了。本文就按照Picasso的用法即with-load-into这个流程来了,主要还是解析怎么从一个uri或者res到能够将Bitmap显示在ImageView上的一个过程。

Picasso#with

这个with方法是入口,没什么好讲的,返回一个单利Builder而已,重要的是Builder.builder有哪些操作:

  public static Picasso with() {
    if (singleton == null) {
      synchronized (Picasso.class) {
        if (singleton == null) {
          if (PicassoProvider.context == null) {
            throw new IllegalStateException("context == null");
          }
          singleton = new Builder(PicassoProvider.context).build();
        }
      }
    }
    return singleton;
  }

Picasso#Builder#build

public Picasso build() {
      Context context = this.context;

      if (downloader == null) {
       // 默认就是 okhttp下载器
        downloader = new OkHttp3Downloader(context);
      }
      if (cache == null) {
        // 配置内存缓存,大小为手机内存的15%
        cache = new LruCache(context);
      }
      if (service == null) {
        // 配置Picaso 线程池,核心池大小为3
        service = new PicassoExecutorService();
      }
      if (transformer == null) {
        // 配置请求转换器,默认的请求转换器没有做任何事,直接返回原请求
        transformer = RequestTransformer.IDENTITY;
      }

      // 创建一个统计类
      Stats stats = new Stats(cache);
      // 分发器 ,初始化调度者
      Dispatcher dispatcher = new Dispatcher(context, service, HANDLER, downloader, cache, stats);

      // 返回一个Picasso实例
      return new Picasso(context, dispatcher, cache, listener, transformer, requestHandlers, stats,
          defaultBitmapConfig, indicatorsEnabled, loggingEnabled);
    }

大部分初始化变量都已经在上述代码中解释过了,注意下requestHandlers其作用是创建处理器集合,用于处理不同的加载请求,其初始化过程是在Picasso的构造器中。

// ResourceRequestHandler needs to be the first in the list to avoid
// forcing other RequestHandlers to perform null checks on request.uri
// to cover the (request.resourceId != 0) case.
allRequestHandlers.add(new ResourceRequestHandler(context));
    if (extraRequestHandlers != null) {
      allRequestHandlers.addAll(extraRequestHandlers);
    }
// 从contactsPhoto加载图片
allRequestHandlers.add(new ContactsPhotoRequestHandler(context));
// 从MediaStore加载
allRequestHandlers.add(new MediaStoreRequestHandler(context));
allRequestHandlers.add(new ContentStreamRequestHandler(context));
// 本地Asset加载
allRequestHandlers.add(new AssetRequestHandler(context));
allRequestHandlers.add(new FileRequestHandler(context));
// 从网络加载图片
allRequestHandlers.add(new NetworkRequestHandler(dispatcher.downloader, stats));
requestHandlers = Collections.unmodifiableList(allRequestHandlers);

接下来按照Picasso的用法就应该去看load方法了:

Picasso#load

  public RequestCreator load(@Nullable Uri uri) {
    return new RequestCreator(this, uri, 0);
  }

  public RequestCreator load(@Nullable String path) {
    if (path == null) {
      return new RequestCreator(this, null, 0);
    }
    if (path.trim().length() == 0) {
      throw new IllegalArgumentException("Path must not be empty.");
    }
    return load(Uri.parse(path));
  }

  public RequestCreator load(@NonNull File file) {
    if (file == null) {
      return new RequestCreator(this, null, 0);
    }
    return load(Uri.fromFile(file));
  }

  public RequestCreator load(@DrawableRes int resourceId) {
    if (resourceId == 0) {
      throw new IllegalArgumentException("Resource ID must not be zero.");
    }
    return new RequestCreator(this, null, resourceId);
  }

从上述知道load这个方法有多种重载,其作用是为了从不同地方加载资源。核心还是RequestCreator这个类。接下来的分析自然要到 RequestCreator中。并且load只是把uri或者path传递过里啊,后续就没啥作用了,下一步继续要分析的就是into方法了。

RequestCreator#into

同样,into方法也有多个重载版本,选择target为Imageview作为分析对象

  public void into(ImageView target, Callback callback) {
    long started = System.nanoTime();
    // 检查是否为主线程,这里为什么需要去检测是否是主线程?
    checkMain();

    // Image存放的target不能为null
    if (target == null) {
      throw new IllegalArgumentException("Target must not be null.");
    }
	
    // 不存在
    if (!data.hasImage()) {
      picasso.cancelRequest(target);
      if (setPlaceholder) {
        setPlaceholder(target, getPlaceholderDrawable());
      }
      return;
    }

    // 是否调用了fit(),如果是,代表需要将image调整为ImageView的大小
    if (deferred) {
      // fit不能有resize一起使用
      if (data.hasSize()) {
        throw new IllegalStateException("Fit cannot be used with resize.");
      }
      int width = target.getWidth();
      int height = target.getHeight();
      if (width == 0 || height == 0 || target.isLayoutRequested()) {
        if (setPlaceholder) {
          setPlaceholder(target, getPlaceholderDrawable());
        }
        picasso.defer(target, new DeferredRequestCreator(this, target, callback));
        return;
      }
      // resize模式
      data.resize(width, height);
    }

    // 创建Request
    Request request = createRequest(started);
    String requestKey = createKey(request);

    // 根据缓存策略从缓存中读取
    if (shouldReadFromMemoryCache(memoryPolicy)) {
      Bitmap bitmap = picasso.quickMemoryCacheCheck(requestKey);
      if (bitmap != null) {
        picasso.cancelRequest(target);
        setBitmap(target, picasso.context, bitmap, MEMORY, noFade, picasso.indicatorsEnabled);
        if (picasso.loggingEnabled) {
          log(OWNER_MAIN, VERB_COMPLETED, request.plainId(), "from " + MEMORY);
        }
        if (callback != null) {
          callback.onSuccess();
        }
        return;
      }
    }

    // 设置Placeholder
    if (setPlaceholder) {
      setPlaceholder(target, getPlaceholderDrawable());
    }

    // 构建一个Action对象,由于我们是往ImageView里加载图片,所以这里创建的是一个ImageViewAction对象.
    Action action =
        new ImageViewAction(picasso, target, request, memoryPolicy, networkPolicy, errorResId,
            errorDrawable, requestKey, tag, callback, noFade);

    // action提交到
    picasso.enqueueAndSubmit(action);
  }

讲道理此时,应该从picasso.enqueueAndSubmit(action);跟进去,但是需要先去了解Action代表什么,跟之前的Request有关系吗?

  • Request关注的是请求本身,比如请求的源、id、开始时间、图片变换配置、优先级等等,而Action则代表的是一个加载任务,所以不仅需要 Request对象的引用,还需要Picasso实例,是否重试加载等等
public final class Request {
  private static final long TOO_LONG_LOG = TimeUnit.SECONDS.toNanos(5);
  // 给request赋予的唯一的ID
  int id;
  // request提交到线程池中的时间记录
  long started;
  // 该request的网络请求策略
  int networkPolicy;
  // 加载源头
  public final Uri uri;  
  public final int resourceId;
  public final String stableKey;
  /** List of custom transformations to be applied after the built-in transformations. */
  public final List<Transformation> transformations;
  /** Target image width for resizing. */
  public final int targetWidth;
  /** Target image height for resizing. */
  public final int targetHeight;
  public final boolean centerCrop;
  public final int centerCropGravity;
  public final boolean centerInside;
  public final boolean onlyScaleDown;
  /** Amount to rotate the image in degrees. */
  public final float rotationDegrees;
  /** Rotation pivot on the X axis. */
  public final float rotationPivotX;
  /** Rotation pivot on the Y axis. */
  public final float rotationPivotY;
  /** Whether or not {@link #rotationPivotX} and {@link #rotationPivotY} are set. */
  public final boolean hasRotationPivot;
  /** True if image should be decoded with inPurgeable and inInputShareable. */
  public final boolean purgeable;
  /** Target image config for decoding. */
  public final Bitmap.Config config;
  // request的请求优先级
  public final Priority priority;
  • Action中WeakReference target,它持有的是Target(ImageView..)的弱引用,这样可以保证加载时间很长的情况下 也不会影响到Target的回收了.这些Action类的实现类不仅保存了这次加载需要的所有信息,还提供了加载完成后的回调方法.也是由子类实现并用来完成不同的调用的
  abstract class Action<T> {
  // picasso单例
  final Picasso picasso;
  // 当前任务的request  
  final Request request;  
  // 目标,imageview
  final WeakReference<T> target; 
  // 是否渐进渐出 
  final boolean noFade;  
  // 内存缓存策略
  final int memoryPolicy;  
  // 网络缓存策略
  final int networkPolicy;  
  // 错误占位图资源id
  final int errorResId;  
  // 错误占位图资源drawable
  final Drawable errorDrawable; 
  // 内存缓存的key 
  final String key; 
  // dispatcher的任务标识
  final Object tag; 
  // 是否再次放到任务队列中
  boolean willReplay; 
  // 是否取消 
  boolean cancelled;  
  // Bitmap加载成功回调
  abstract void complete(Bitmap result, Picasso.LoadedFrom from);
  // 加载失败回调
  abstract void error();

接下来继续前面的步骤,看看如何submit一个Action

  void enqueueAndSubmit(Action action) {
    // 从action中拿到target
    Object target = action.getTarget();
    if (target != null && targetToAction.get(target) != action) {
      // This will also check we are on the main thread.
      cancelExistingRequest(target);
      targetToAction.put(target, action);
    }
    submit(action);
  }

  void submit(Action action) {
    dispatcher.dispatchSubmit(action);
  }

Dispatcher#performSubmit

紧接着上面的代码我们需要看一下Dispatcher这个类相关的信息,如其构造函数:

Dispatcher(Context context, ExecutorService service, Handler mainThreadHandler,
      Downloader downloader, Cache cache, Stats stats)

可以看出声明一个dispatcher需要以下元素:

  • 线程池ExecutorService,默认为3,但是会随着网络的不同而变化
  • Handler处理器
  • 下载器Downloader
  • Cache
  • Stats监控器

还需要关注一下内部类,是一个继承handlerThrea的线程:

 static class DispatcherThread extends HandlerThread {
    DispatcherThread() {
      super(Utils.THREAD_PREFIX + DISPATCHER_THREAD_NAME, THREAD_PRIORITY_BACKGROUND);
    }
  }

那这个内部类的作用是什么呢,注意到了Dispatcher中的构造器中:

 this.handler = new DispatcherHandler(dispatcherThread.getLooper(), this);

原来如此,这样后续的handler操作都是在子线程中进行了,避免阻塞主线程的消息队列了。紧接着还是从 ` dispatcher.dispatchSubmit(action);`继续深入:

 void dispatchSubmit(Action action) {
    handler.sendMessage(handler.obtainMessage(REQUEST_SUBMIT, action));
  }

发送到handler,根据消息机制的Case之后会调用下面这儿的performSubmit方法

void performSubmit(Action action, boolean dismissFailed) {
    if (pausedTags.contains(action.getTag())) {
      pausedActions.put(action.getTarget(), action);
      if (action.getPicasso().loggingEnabled) {
        log(OWNER_DISPATCHER, VERB_PAUSED, action.request.logId(),
            "because tag '" + action.getTag() + "' is paused");
      }
      return;
    }

    // 获取 BitmapHunter对象,从缓存中
    BitmapHunter hunter = hunterMap.get(action.getKey());
    if (hunter != null) {
      hunter.attach(action);
      return;
    }

    // 判断线程池有咩有关闭
    if (service.isShutdown()) {
      if (action.getPicasso().loggingEnabled) {
        log(OWNER_DISPATCHER, VERB_IGNORED, action.request.logId(), "because shut down");
      }
      return;
    }

    // 创建BitmapHunter对象
    hunter = forRequest(action.getPicasso(), this, cache, stats, action);
    hunter.future = service.submit(hunter);
    hunterMap.put(action.getKey(), hunter);
    if (dismissFailed) {
      failedActions.remove(action.getTarget());
    }

    if (action.getPicasso().loggingEnabled) {
      log(OWNER_DISPATCHER, VERB_ENQUEUED, action.request.logId());
    }
  }

上面代码中BitmapHunnter是一个Runnable对象,创建好之后会先判断线程池有没有关闭,没有关闭就丢进去让线程池执行,线程池接受到之后就会执行Runnable对象中的run方法,跟进去看一下:

BitmapHunter#run

  @Override public void run() {
    try {
      // 更新当前线程名字
      updateThreadName(data);

      if (picasso.loggingEnabled) {
        log(OWNER_HUNTER, VERB_EXECUTING, getLogIdsForHunter(this));
      }

      // 调用hunt方法
      result = hunt();

      // 通过dispatcher处理结果
      if (result == null) {
        dispatcher.dispatchFailed(this);
      } else {
        dispatcher.dispatchComplete(this);
      }
    } catch (NetworkRequestHandler.ResponseException e) {
      
    } finally {
      Thread.currentThread().setName(Utils.THREAD_IDLE_NAME);
    }
  }

先进入到hunt方法中,看看返回是什么result?

BitmapHunter#hunt

 Bitmap hunt() throws IOException {
    Bitmap bitmap = null;
    // 先从缓存中获取
    if (shouldReadFromMemoryCache(memoryPolicy)) {
      bitmap = cache.get(key);
      if (bitmap != null) {
        // 统计命中率
        stats.dispatchCacheHit();
        loadedFrom = MEMORY;
        if (picasso.loggingEnabled) {
          log(OWNER_HUNTER, VERB_DECODED, data.logId(), "from cache");
        }
        return bitmap;
      }
    }

    // 缓存没有的话,再调用requestHandler.load,选择什么requesthandler就看具体情况了
    networkPolicy = retryCount == 0 ? NetworkPolicy.OFFLINE.index : networkPolicy;
    RequestHandler.Result result = requestHandler.load(data, networkPolicy);
    if (result != null) {
      loadedFrom = result.getLoadedFrom();
      exifOrientation = result.getExifOrientation();
      bitmap = result.getBitmap();

      // If there was no Bitmap then we need to decode it from the stream.
      if (bitmap == null) {
        Source source = result.getSource();
        try {
          // 压缩
          bitmap = decodeStream(source, data);
        } finally {
          try {
            //noinspection ConstantConditions If bitmap is null then source is guranteed non-null.
            source.close();
          } catch (IOException ignored) {
          }
        }
      }
    }

    if (bitmap != null) {
      if (picasso.loggingEnabled) {
        log(OWNER_HUNTER, VERB_DECODED, data.logId());
      }
      stats.dispatchBitmapDecoded(bitmap);
     // 变换操作
      if (data.needsTransformation() || exifOrientation != 0) {
        synchronized (DECODE_LOCK) {
          if (data.needsMatrixTransform() || exifOrientation != 0) {
            bitmap = transformResult(data, bitmap, exifOrientation);
            if (picasso.loggingEnabled) {
              log(OWNER_HUNTER, VERB_TRANSFORMED, data.logId());
            }
          }
          if (data.hasCustomTransformations()) {
            bitmap = applyCustomTransformations(data.transformations, bitmap);
            if (picasso.loggingEnabled) {
              log(OWNER_HUNTER, VERB_TRANSFORMED, data.logId(), "from custom transformations");
            }
          }
        }
        if (bitmap != null) {
          stats.dispatchBitmapTransformed(bitmap);
        }
      }
    }
    // 注意这里返回的bitmap
    return bitmap;
  }

到这里注意逻辑别乱,hunt方法是在run方法中间调用的,最后需要知道hunt方法返回结果之后,图片成功显示与否怎么办呢?在run方法中:

 // 通过dispatcher处理结果
if (result == null) {
   dispatcher.dispatchFailed(this);
  } else {
   dispatcher.dispatchComplete(this);
}

也是通过handler机制发送消息来更新的,最后辗转会到下面的performComplete这方法中

void performComplete(BitmapHunter hunter) {
    // 写入缓存,根据之前的缓存策略
    if (shouldWriteToMemoryCache(hunter.getMemoryPolicy())) {
      cache.set(hunter.getKey(), hunter.getResult());
    }
    // 从hunterMap中移除
    hunterMap.remove(hunter.getKey());
    // 批量处理hunter,等待200ms
    batch(hunter);
    if (hunter.getPicasso().loggingEnabled) {
      log(OWNER_DISPATCHER, VERB_BATCHED, getLogIdsForHunter(hunter), "for completion");
    }
  }

进入到上面的batch这个操作中查看一下,顾名思义是批处理的意思,看到下面的代码,其实操作还是放进batch#add这个函数中,本意是为了将操作缓存一下,等空闲的时候再拿出来处理,这样可以更有逻辑性,避免出现ANR现象

private void batch(BitmapHunter hunter) {
    if (hunter.isCancelled()) {
      return;
    }
    if (hunter.result != null) {
      hunter.result.prepareToDraw();
    }
    batch.add(hunter);
    if (!handler.hasMessages(HUNTER_DELAY_NEXT_BATCH)) {
      handler.sendEmptyMessageDelayed(HUNTER_DELAY_NEXT_BATCH, BATCH_DELAY);
    }
  }

发送HUNTER_DELAY_NEXT_BATCH消息,这个消息最后会出发performBatchComplete()方法,performBatchComplete()里则是通过mainThreadHandler将BitmapHunter的List发送到主线程处理,所以去看看mainThreadHandler的handleMessage()方法:

@Override public void handleMessage(final Message msg) {
      switch (msg.what) {
        ...
        case HUNTER_DELAY_NEXT_BATCH: {
          dispatcher.performBatchComplete();
          break;
        }
        ...
      }
}

顺其自然接着看:

Dispathcer#performBatchComplete

void performBatchComplete() {
    List<BitmapHunter> copy = new ArrayList<>(batch);
    // 清除batch
    batch.clear();
    mainThreadHandler.sendMessage(mainThreadHandler.obtainMessage(HUNTER_BATCH_COMPLETE, copy));
    logBatch(copy);
  }

这个mainThreadHandler是在Dispatcher实例化时由外部传递进来的,在前面的分析中看到,Picasso在通过Builder创建时会对Dispatcher进行实例化,在那个地方将主线程的handler传了进来,回到Picasso这个类,看到其有一个静态成员变量HANDLER,这样我们也就清楚了。

  static final Handler HANDLER = new Handler(Looper.getMainLooper()) {
    @Override public void handleMessage(Message msg) {
      switch (msg.what) {
        case HUNTER_BATCH_COMPLETE: {
          @SuppressWarnings("unchecked") List<BitmapHunter> batch = (List<BitmapHunter>) msg.obj;
          //noinspection ForLoopReplaceableByForEach
          for (int i = 0, n = batch.size(); i < n; i++) {
            // 对每个BitmapHunter采用complete方法
            BitmapHunter hunter = batch.get(i);
            hunter.picasso.complete(hunter);
          }
          break;
        }
        case REQUEST_GCED: {
          Action action = (Action) msg.obj;
          if (action.getPicasso().loggingEnabled) {
            log(OWNER_MAIN, VERB_CANCELED, action.request.logId(), "target got garbage collected");
          }
          action.picasso.cancelExistingRequest(action.getTarget());
          break;
        }
        case REQUEST_BATCH_RESUME:
          @SuppressWarnings("unchecked") List<Action> batch = (List<Action>) msg.obj;
          //noinspection ForLoopReplaceableByForEach
          for (int i = 0, n = batch.size(); i < n; i++) {
            Action action = batch.get(i);
            action.picasso.resumeAction(action);
          }
          break;
        default:
          throw new AssertionError("Unknown handler message received: " + msg.what);
      }
    }
  };

代码很长,重点关注case HUNTER_BATCH_COMPLETE: 其对batch中存储的操作分步骤处理,进而进入到complete中,隐约感觉图片快要显示出来了,紧跟complete这个方法

void complete(BitmapHunter hunter) {
    Action single = hunter.getAction();
    List<Action> joined = hunter.getActions();

    boolean hasMultiple = joined != null && !joined.isEmpty();
    boolean shouldDeliver = single != null || hasMultiple;

    if (!shouldDeliver) {
      return;
    }

    Uri uri = hunter.getData().uri;
    Exception exception = hunter.getException();
    Bitmap result = hunter.getResult();
    LoadedFrom from = hunter.getLoadedFrom();

    // 分发单独的Action
    if (single != null) {
      deliverAction(result, from, single, exception);
    }

    // 有重复的
    if (hasMultiple) {
      //noinspection ForLoopReplaceableByForEach
      for (int i = 0, n = joined.size(); i < n; i++) {
        Action join = joined.get(i);
        // 注意在这里派发Action
        deliverAction(result, from, join, exception);
      }
    }

    if (listener != null && exception != null) {
      listener.onImageLoadFailed(this, uri, exception);
    }
  }

紧跟deliverAction方法中去看一看:

 private void deliverAction(Bitmap result, LoadedFrom from, Action action, Exception e) {
    if (action.isCancelled()) {
      return;
    }
    if (!action.willReplay()) {
      targetToAction.remove(action.getTarget());
    }
    if (result != null) {
      if (from == null) {
        throw new AssertionError("LoadedFrom cannot be null.");
      }
      // 回调action中的complete
      action.complete(result, from);
      if (loggingEnabled) {
        log(OWNER_MAIN, VERB_COMPLETED, action.request.logId(), "from " + from);
      }
    } else {
      // 回调action中的complete
      action.error(e);
      if (loggingEnabled) {
        log(OWNER_MAIN, VERB_ERRORED, action.request.logId(), e.getMessage());
      }
    }
  }

Action#complete

@Override public void complete(Bitmap result, Picasso.LoadedFrom from) {
    if (result == null) {
      throw new AssertionError(
          String.format("Attempted to complete action with no result!\n%s", this));
    }

    // imageview作为target,来提供图片显示的位置
    ImageView target = this.target.get();
    // 判断target是否为null
    if (target == null) {
      return;
    }

    Context context = picasso.context;
    boolean indicatorsEnabled = picasso.indicatorsEnabled;

    // setbitmap操作来将图片显示上去
    PicassoDrawable.setBitmap(target, context, result, from, noFade, indicatorsEnabled);

    // 回调成功接口callback
    if (callback != null) {
      callback.onSuccess();
    }
}

关注的点

本篇文章重点记录Picasso请求显示图片的过程,完整的分析做的还不是很全面。继续深入可以从下面的方法来挖掘这个库,篇幅有限,本篇就先到这里了。

  • 缓存策略
  • 预加载
  • 图片变换
如果觉得本文对你有帮助,请打赏以回报我的劳动