Fresco图片解码部分源码分析及webp vs jpeg指标对比

前言

之前的文章写过webp图片的调研,这篇分析一下fresco的decoder部分的源码,同时从响应、下载、解码、大小四个指标上对比同一张图片的webp 与jpg格式。这里响应时间应该与图片格式本身没有关系,但这里为了对服务器接口做一个测试也加入了对比;下载时间应该与图片size成正相关,这里也加入对比,看看结果是否符合预期。根据google官网介绍,目前WebP与JPG相比较,编解码速度上,毫秒级别上:编码速度webp比jpg慢10倍,解码速度慢1.5倍。在我们的使用场景下,编码速度的影响可以被忽略,因为服务器会在用户第一次请求时,编码生成jpg图片对应的webp图片,之后都会被缓存下来,可以认为几乎所有用户的请求都能命中缓存。解码方面,则是每个用户拿到webp图片都必经的开销,因此解码速度是本次测试对比的关键指标。

Fresco WebP支持

我们的客户端使用的是fresco图片库,根据其官方文档说明

Android added webp support in version 4.0 and improved it in 4.2.1:
4.0+ (Ice Cream Sandwich): basic webp support
4.2.1+ (Jelly Beam MR1): support for transparency and losless wepb
Fresco handles webp images by default if the OS supports it. So you can use webp with 4.0+ and trasparency and losless webps from 4.2.1.
Fresco also supports webp for older OS versions. The only thing you need to do is add thewebpsupportlibrary to your dependencies. So if you want to use webps on Gingerbread just add the following line to your gradle build file:
compile 'com.facebook.fresco:webpsupport:1.3.0'

因此我们需要引入webpsupprot库,这样子fresco会处理对webp的支持。下面也会从源码上分析,fresco是如何解码webp的。

Fresco Producer源码分析

Producer继承结构

首先我们看一下Frecso中Producer的继承结构图:

fresco producer继承关系

Producer流水线

ProducerSequenceFactory是专门将生成各类链接起来的Producer,根据其中的逻辑,这里将可能涉及层次最深的Uri——网络Uri的Producer链在此列出,它会到每个缓存中查找数据,最后如果都没有命中,则会去网络上下载。

顺序 Producer 是否必须 功能
1 PostprocessedBitmapMemoryCacheProducer 在Bitmap缓存中查找被PostProcess过的数据
2 PostprocessorProducer 对下层Producer传上来的数据进行PostProcess
3 BitmapMemoryCacheGetProducer 使Producer序列只读
4 ThreadHandoffProducer 使下层Producer工作在后台进程中执行
5 BitmapMemoryCacheKeyMultiplexProducer 使多个相同已解码内存缓存键的ImageRequest都从相同Producer中获取数据
6 BitmapMemoryCacheProducer 从已解码的内存缓存中获取数据
7 DecodeProducer 将下层Producer产生的数据解码
8 ResizeAndRotateProducer 将下层Producer产生的数据变换
9 EncodedCacheKeyMultiplexProducer 使多个相同未解码内存缓存键的ImageRequest都从相同Producer中获取数据
10 EncodedMemoryCacheProducer 从未解码的内存缓存中获取数据
11 DiskCacheProducer 从文件缓存中获取数据
12 WebpTranscodeProducer Transcodes WebP to JPEG / PNG
13 NetworkFetchProducer 从网络上获取数据

<br />
为了获得每一张网络图片的大小、响应时间、下载时间、decode时间,我们需要探索Fresco的源码,挂上钩子去得到这些指标;这里我们关心DecoderProducerNetworkFetchProducer,顾名思义,这两个Producer分别用于解码和网络加载相关。

DecodeProducer解码过程

DecodeProducer负责将未解码的数据生产出解码的数据。先看produceResults方法。

  @Override
  public void produceResults(final Consumer<CloseableReference<CloseableImage>> consumer, final ProducerContext producerContext) {
    final ImageRequest imageRequest = producerContext.getImageRequest();
    ProgressiveDecoder progressiveDecoder;
    if (!UriUtil.isNetworkUri(imageRequest.getSourceUri())) {
      progressiveDecoder = new LocalImagesProgressiveDecoder(consumer, producerContext, mDecodeCancellationEnabled);
    } else {
      ProgressiveJpegParser jpegParser = new ProgressiveJpegParser(mByteArrayPool);
      progressiveDecoder = new NetworkImagesProgressiveDecoder(consumer, producerContext, jpegParser, mProgressiveJpegConfig, mDecodeCancellationEnabled);
    }
    mInputProducer.produceResults(progressiveDecoder, producerContext);
  }```

通过判断uri的类型 选择不同的渐近式解释器,local和network都继承自ProgressiveDecoder

在`ProgressiveDecoder`的构造方法中,doDecode(encodedImage, isLast) 进行解析。而真正解析的则是`ImageDecode`r#decodeImage方法,这个方法将encodedImage解析成`CloseableImage`:
```Java
    /** Performs the decode synchronously. */
    private void doDecode(EncodedImage encodedImage, @Status int status) {
      if (isFinished() || !EncodedImage.isValid(encodedImage)) {
        return;
      }
      final String imageFormatStr;
      ImageFormat imageFormat = encodedImage.getImageFormat();
      if (imageFormat != null) {
        imageFormatStr = imageFormat.getName();
      } else {
        imageFormatStr = "unknown";
      }
      final String encodedImageSize;
      final String sampleSize;
      final boolean isLast = isLast(status);
      final boolean isLastAndComplete = isLast && !statusHasFlag(status, IS_PARTIAL_RESULT);
      final boolean isPlaceholder = statusHasFlag(status, IS_PLACEHOLDER);
      if (encodedImage != null) {
        encodedImageSize = encodedImage.getWidth() + "x" + encodedImage.getHeight();
        sampleSize = String.valueOf(encodedImage.getSampleSize());
      } else {
        // We should never be here
        encodedImageSize = "unknown";
        sampleSize = "unknown";
      }
      final String requestedSizeStr;
      final ResizeOptions resizeOptions = mProducerContext.getImageRequest().getResizeOptions();
      if (resizeOptions != null) {
        requestedSizeStr = resizeOptions.width + "x" + resizeOptions.height;
      } else {
        requestedSizeStr = "unknown";
      }
      try {
        long queueTime = mJobScheduler.getQueuedTime();
        long decodeDuration = -1;
        String imageUrl = encodedImage.getEncodedCacheKey().getUriString();
        int length = isLastAndComplete || isPlaceholder ? encodedImage.getSize() : getIntermediateImageEndOffset(encodedImage);
        QualityInfo quality = isLastAndComplete || isPlaceholder ? ImmutableQualityInfo.FULL_QUALITY : getQualityInfo();

        mProducerListener.onProducerStart(mProducerContext.getId(), PRODUCER_NAME);
        CloseableImage image = null;
        try {
          long nowTime = System.currentTimeMillis();
          image = mImageDecoder.decode(encodedImage, length, quality, mImageDecodeOptions);
          decodeDuration = System.currentTimeMillis() - nowTime;
        } catch (Exception e) {
          Map<String, String> extraMap = getExtraMap(image, imageUrl, queueTime, decodeDuration, quality, isLast, imageFormatStr, encodedImageSize, requestedSizeStr, sampleSize);
          mProducerListener.onProducerFinishWithFailure(mProducerContext.getId(), PRODUCER_NAME, e, extraMap);
          handleError(e);
          return;
        }
        Map<String, String> extraMap = getExtraMap(image, imageUrl, queueTime, decodeDuration, quality, isLast, imageFormatStr, encodedImageSize, requestedSizeStr, sampleSize);
        mProducerListener.onProducerFinishWithSuccess(mProducerContext.getId(), PRODUCER_NAME, extraMap);
        handleResult(image, status);
      } finally {
        EncodedImage.closeSafely(encodedImage);
      }
    }

因此我们在#doDecoder方法在decode前后插入解码时长计算。

  long nowTime = System.currentTimeMillis();
  image = mImageDecoder.decode(encodedImage, length, quality, mImageDecodeOptions);
  decodeDuration = System.currentTimeMillis() - nowTime;

ImageDecoder

DecoderProducer 中是依赖ImageDecoder类,用来将未解码的EncodeImage解码成对应的CloseableImageImageDecoder中先判断未解码的图片类型:

  private final ImageDecoder mDefaultDecoder = new ImageDecoder() {
    @Override
    public CloseableImage decode(EncodedImage encodedImage, int length, QualityInfo qualityInfo, ImageDecodeOptions options) {
      ImageFormat imageFormat = encodedImage.getImageFormat();
      if (imageFormat == DefaultImageFormats.JPEG) {
        return decodeJpeg(encodedImage, length, qualityInfo, options);
      } else if (imageFormat == DefaultImageFormats.GIF) {
        return decodeGif(encodedImage, length, qualityInfo, options);
      } else if (imageFormat == DefaultImageFormats.WEBP_ANIMATED) {
        return decodeAnimatedWebp(encodedImage, length, qualityInfo, options);
      } else if (imageFormat == ImageFormat.UNKNOWN) {
        throw new IllegalArgumentException("unknown image format");
      }
      return decodeStaticImage(encodedImage, options);
    }
  };

ImageFormatChecker

这个类是根据输入流来确定图片的类型。基本原理是根据头标识去确定类型。根据代码能看出,这里分为几种。

  public static final ImageFormat JPEG = new ImageFormat("JPEG", "jpeg");
  public static final ImageFormat PNG = new ImageFormat("PNG", "png");
  public static final ImageFormat GIF = new ImageFormat("GIF", "gif");
  public static final ImageFormat BMP = new ImageFormat("BMP", "bmp");
  public static final ImageFormat WEBP_SIMPLE = new ImageFormat("WEBP_SIMPLE", "webp");
  public static final ImageFormat WEBP_LOSSLESS = new ImageFormat("WEBP_LOSSLESS", "webp");
  public static final ImageFormat WEBP_EXTENDED = new ImageFormat("WEBP_EXTENDED", "webp");
  public static final ImageFormat WEBP_EXTENDED_WITH_ALPHA = new ImageFormat("WEBP_EXTENDED_WITH_ALPHA", "webp");
  public static final ImageFormat WEBP_ANIMATED = new ImageFormat("WEBP_ANIMATED", "webp");

本篇我们关心以下几种:

  • JPEG
  • WEBP_SIMPLE
  • GIF
  • WEBP_ANIMATED

从是否静态图上来看,为两种:

  • 可动 ,用AnimatedImageFactory进行解析
  • 不可动,用PlatformDecoder进行解析

AnimatedImageFactory

AnimatedImageFactory是一个接口,他的实现类是AnimatedImageFactoryImpl。
在这个类的静态方法块种,通过如下代码 来构造其他依赖包中的对象,这个小技巧我们可以get一下。

  private static AnimatedImageDecoder loadIfPresent(final String className) {
    try {
      Class<?> clazz = Class.forName(className);
      return (AnimatedImageDecoder) clazz.newInstance();
    } catch (Throwable e) {
      return null;
    }
  }

  static {
    sGifAnimatedImageDecoder = loadIfPresent("com.facebook.animated.gif.GifImage");
    sWebpAnimatedImageDecoder = loadIfPresent("com.facebook.animated.webp.WebPImage");
  }

AnimatedImageDecoder又分别有两个实现:

  • WebpImage
  • GifImage
WebpImage与GifImage

解析分为两个步骤:

  1. 通过AnimatedImageDecoder解析出AnimatedImage
  2. 利用getCloseableImage从AnimatedImage中构造出CloseableAnimatedImage。这是CloseableImage的之类。
    getCloseableImage的逻辑如下:
  3. 用decodeAllFrames解析出所有帧
  4. 用createPreviewBitmap构造预览的bitmap
  5. 构造AnimatedImageResult对象
  6. AnimatedImageResult构造CloseableAnimatedImage对象。

PlatformDecoder

PlatformDecoder是一个接口,代表不同平台。我们看他的实现类有哪些:

PlatformDecoder具体实现
public interface PlatformDecoder {
  /**
   * Creates a bitmap from encoded bytes. Supports JPEG but callers should use {@link
   * #decodeJPEGFromEncodedImage} for partial JPEGs.
   *
   * @param encodedImage the reference to the encoded image with the reference to the encoded bytes
   * @param bitmapConfig the {@link android.graphics.Bitmap.Config} used to create the decoded
   * Bitmap
   * @return the bitmap
   * @throws TooManyBitmapsException if the pool is full
   * @throws java.lang.OutOfMemoryError if the Bitmap cannot be allocated
   */
  CloseableReference<Bitmap> decodeFromEncodedImage(final EncodedImage encodedImage, Bitmap.Config bitmapConfig);

  /**
   * Creates a bitmap from encoded JPEG bytes. Supports a partial JPEG image.
   *
   * @param encodedImage the reference to the encoded image with the reference to the encoded bytes
   * @param bitmapConfig the {@link android.graphics.Bitmap.Config} used to create the decoded
   * Bitmap
   * @param length the number of encoded bytes in the buffer
   * @return the bitmap
   * @throws TooManyBitmapsException if the pool is full
   * @throws java.lang.OutOfMemoryError if the Bitmap cannot be allocated
   */
  CloseableReference<Bitmap> decodeJPEGFromEncodedImage(EncodedImage encodedImage, Bitmap.Config bitmapConfig, int length);
}


getBitmapFactoryOptions 获取BitmapFactory.Options
decodeByteArrayAsPurgeable 获取bitmap
pinBitmap 真正的decode

NetworkFetchProducer

NetworkFetchProducer负责从网络层获取图片流,持有NetworkFetcher的实现类;测试代码中,我们添加了OkHttp3的OkHttpNetworkFetcher作为fetcher;我们关心NetworkFetchProducer中的这个方法:

  private void handleFinalResult(PooledByteBufferOutputStream pooledOutputStream, FetchState fetchState) {
    Map<String, String> extraMap = getExtraMap(fetchState, pooledOutputStream.size());
    ProducerListener listener = fetchState.getListener();
    listener.onProducerFinishWithSuccess(fetchState.getId(), PRODUCER_NAME, extraMap);
    listener.onUltimateProducerReached(fetchState.getId(), PRODUCER_NAME, true);
    notifyConsumer(pooledOutputStream, Consumer.IS_LAST | fetchState.getOnNewResultStatusFlags(), fetchState.getResponseBytesRange(), fetchState.getConsumer());
  }

该方法中FetchState记录了一张图片从服务端响应到IO读取的耗时记录。同样的,也是通过ProducerListener的#onProducerFinishWithSuccess方法回调出去。

计算decode & fecth的时间

每个Producer的接口实现,都会持有ProducerContext,�其中的ProducerListener会回调Producer各个阶段的事件。我们关心这个方法:

/**

  • Called when a producer successfully finishes processing current unit of work.
  • @param extraMap Additional parameters about the producer. This map is immutable and will
  • throw an exception if attempts are made to modify it.
    */
    void onProducerFinishWithSuccess(String requestId, String producerName, @Nullable Map<String, >String> extraMap);

该方法会在Producer结束时回调出来,我们利用Fresco包里的RequestLoggingListener,便可监听到DecoderProducerNetworkFetchProducer的回调。

    @Override
    public void onCreate() {
        super.onCreate();
        FLog.setMinimumLoggingLevel(FLog.VERBOSE);
        Set<RequestListener> listeners = new HashSet<>(1);
        listeners.add(new RequestLoggingListener());
        ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
            .setRequestListeners(listeners)
            .setNetworkFetcher(new OkHttpNetworkFetcher(OKHttpFactory.getInstance().getOkHttpClient()))
            .build();
        DraweeConfig draweeConfig = DraweeConfig.newBuilder().setDrawDebugOverlay(DebugOverlayHelper.isDebugOverlayEnabled(this)).build();
        Fresco.initialize(this, config, draweeConfig);
        Fresco.getImagePipeline().clearDiskCaches();
    }

我们通过在Fresco初始化Builder中加入RequestLoggingListener,并改造RequestLoggingListener的onProducerFinishWithSuccess方法:

  @Override
  public synchronized void onProducerFinishWithSuccess(String requestId, String producerName, @Nullable Map<String, String> extraMap) {
    if (FLog.isLoggable(FLog.VERBOSE)) {
      Pair<String, String> mapKey = Pair.create(requestId, producerName);
      Long startTime = mProducerStartTimeMap.remove(mapKey);
      long currentTime = getTime();
      long producerDuration = -1;
      FLog.v(TAG, "time %d: onProducerFinishWithSuccess: " + "{requestId: %s, producer: %s, elapsedTime: %d ms, extraMap: %s}", currentTime, requestId, producerName, producerDuration = getElapsedTime(startTime, currentTime), extraMap);
      if (sOnProducer != null) {
        sOnProducer.onProducer(producerDuration, extraMap, producerName);
      }
    }
  }

通过将Producer的信息回调给外面,至此我们就拿到了每一个Producer的回调信息,通过producerName的过滤就可以拿到关心的信息,这里我们关心DecoderProducerNetworkFetchProducer的信息。

decode时间计算

DecoderProducer的doDecode方法中插入:

 long nowTime = System.currentTimeMillis();
 image = mImageDecoder.decode(encodedImage, length, quality, mImageDecodeOptions);
 decodeDuration = System.currentTimeMillis() - nowTime;

decodeDuration放入onProducerFinishWithSuccess的extraMap当中

network fecther时间计算

OkHttpNetworkFetcher中定义着几个常量值:

  public static final String QUEUE_TIME = "resp_time"; //修改为响应时间
  public static final String FETCH_TIME = "fetch_time";
  public static final String TOTAL_TIME = "total_time";
  public static final String IMAGE_SIZE = "image_size";
  public static final String IMAGE_URL = "image_url"; //新增
  • QUEUE_TIME为请求丢入请求线程池到最后请求成功响应的时间
  • FETCH_TIME为从response读完IO流的时间
  • IMAGE_SIZE为response header中的content-length,即图片大小

这些数据都最终会被丢入extraMap,回调给外面。

数据对比

jpg&webp指标对比:

jpg webp指标对比
格式 图片数 大小 响应时间 下载时间 解码时间 总用时
jpg 100 33497748B / 31.9MB 3384ms 5582ms 7225ms 16191ms
webp 100 11127628B / 10.6MB 3388ms 2552ms 9806ms 15746ms

以上数据经过几轮测试,都接近这个数据对比。 图片源来自项目线上的图片,图片接口来自公司CND接口,使用相同quality参数,带同一张图片的不同格式参数。解码总时间大概是JPG:WEBP = 1 : 1.3左右,接近官方的1.5倍性能差距。总大小上,webp几乎只有jpg的1/3,远超超官方的30%,这个估计是大多数jpg没有经过压缩就直接上传了。下载时间基本上与size成正比。

http://p1.music.126.net/6OARlbfxOysQJU5iZ8WKSA==/18769762999688243.jpg?imageView=1&type=webp&quality=100

http://p1.music.126.net/6OARlbfxOysQJU5iZ8WKSA==/18769762999688243.jpg?imageView=1&quality=100

gif&anim-webp指标对比:

gif&anim-webp指标对比
格式 图片数 大小 响应时间 下载时间 解码时间 总用时
gif 85 66343142B / 63.26MB 2597ms 6052ms 272ms 8921ms
anim-webp 85 20342068B / 19.39MB 2687ms 3809ms 240ms 6736ms

同样地, 分别取了同一张GIF图片的,原始版本与WEBP版本来对比。

http://p1.music.126.net/rhGo28bJP19-T0xmtpg6jw==/19244752021149272.jpg
http://p1.music.126.net/rhGo28bJP19-T0xmtpg6jw==/19244752021149272.jpg?imageView=1&type=webp&tostatic=0

size压缩对比也接近1:3;另外这里的解码时间是不准确的,因为webp与gif在fresco中都是AnimatedImage,他们的decode调的是nativeCreateFromNativeMemory方法,这个方法返回是对应的WebPImageGifImage对象,表中的解码时间也是构建这个对象的耗时;动图渲染时,主要调用的是AnimatedDrawableBackendImpl中renderFrame方法。但我们可以粗略认为,每一帧的渲染耗时对比,接近jpg与webp的耗时;因为gif与anim-webp分别是由一帧一帧的jpg与webp组成。

总结

webp与jpg相比,包括anim-webp与gif, 在相同的图片质量,图片大小上,webp有着巨大的优势,解码速度毫秒级的差距也完全在接收范围内, 而图片大小最终转化为带宽、存储空间、加载速度上的优势。因此在有条件的情况,app中完全可以用webp来替代jpg格式以提升加载速度、降低存储空间、节省带宽费用。另外在android上,使用fresco作为图片库,可以几乎无成本的接入webp。

参考
1.http://changety.github.io/blog/2016/01/31/webp-research/
2.https://developers.google.com/speed/webp/faq
3.https://guolei1130.github.io/2016/12/13/fresco%E5%9B%BE%E7%89%87decode%E7%9A%84%E5%A4%A7%E4%BD%93%E6%B5%81%E7%A8%8B/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 207,113评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,644评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,340评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,449评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,445评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,166评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,442评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,105评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,601评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,066评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,161评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,792评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,351评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,352评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,584评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,618评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,916评论 2 344

推荐阅读更多精彩内容