Android性能优化:图片的加载和图片缓存技术|SquirrelNote

系列文章:
Android性能优化|SquirrelNote
Android性能优化:布局优化实践|SquirrelNote
Android性能优化:图片的加载和图片缓存技术|SquirrelNote
Android照片墙应用实现|SquirrelNote

前言

本篇主要包含三个方面的内容:

  1. 图片的加载和优化图片的加载
  2. 图片缓存技术和图片三级缓存的实现
  3. 如何优化列表的卡顿现象?由于ListView和GridView要加载大量的子视图,当用户快速滑动时就容易出现卡顿的现象,这里会给出一些优化建议。

一、图片的加载和优化图片的加载

首先,如何优化加载一个Bitmap?我们在编写Android程序的时候经常要用到很多的图片,在大多数情况下,这些图片都会大于我们程序所需要的大小。我们编写的应用程序都是有一定的内存限制,程序占用了过高的内存就容易出现OOM(Out Of Memory)异常。如下代码可以看出每个应用程序最高可用内存:

int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);  
Log.e("TAG", "Max memory is " + maxMemory + "KB");  

因此在展示高分辨率图片的时候,需要将图片进行压缩。

(1).如何加载一个Bitmap呢?

Bitmap在Android中指的是一张图片,png格式、jpg等其他常见的图片格式。
BitmapFactory类提供了四个类方法:decodeFile、decodeResource、decodeStream和decodeByteArray,分别用于支持从文件系统、资源、输入流以及字节数组中加载出一个Bitmap对象,其中decodeFile和decodeResource又间接调用了decodeStream方法,这四个类方法最终在Android的底层实现的,对应着BitmapFactory类的几个native方法。

示例代码:

//设置监听
mBtn_load.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        //拿到位图
        Bitmap bitmap= BitmapFactory.decodeFile("mnt/sdcard/01.jpg");
        mIv.setImageBitmap(bitmap);
    }
});

给按钮设置点击监听,加载图片,多点击几下,会出现如下内存溢出。


image.png

image.png

代表有25601504=3850240个像素点,每个像素点有4个字节,3850240410241024=14.6M,即加载此图片需要内存空间14.6M,一般应用程序申请的最多空间是16M,点击两次就出现内存溢出,这是因为,刚才的图片已经占据了14.6M的内存空间,内存还没来得及回收,再加载14.6M就挂掉了。

Android系统每个进程有最大的VM限制

大多数的真实设备的VM最大的内存申请极限为16M-32M
如果图形资源太大(分辨率太多),必须对图片进行资源处理,处理完毕后才能加载到内存

用户识别出来的图形,受到设备的分辨率的限制,只要我们显示的图形比手机的分辨率高或者一致,用户就看不出来图形的质量被缩放了。

(2).如何对Bitmap进行优化加载呢?

Bitmap优化加载的核心思想就是采用BitmapFactory.Options来加载所需尺寸的图片。
比如通过ImageView来显示图片,很多时候ImageView并没有图片的原始尺寸那么大,如果把整个图片加载进来,再设置给ImageView,ImageView是无法显示原始的图片。通过BitmapFactory.Options就可以按一定的采样率来加载缩小后的图片,将缩小后的图片在ImageView中显示,这样就会降低内存占用从而在一定程度上避免OOM,提高了Bitmap加载时的性能。BitmapFactory提供的加载图片的四个类方法都支持BitmapFactory.Options参数,通过它就可以很方便对一个图片进行采样缩放。

为了避免OOM异常,最好在解析每张图片的时候,先检查一下图片的大小,然后可以决定是把整张图片加载到内存还是把图片压缩后加载到内存。需要考虑以下几个因素:

  • 预估一下加载整张图片所需占用的内存
  • 为了加载一张图片你所愿意提供多少内存
  • 用于展示这张图片的控件的实际的大小
  • 当前设备的屏幕尺寸和分辨率

通过BitmapFactory.Options来缩放图片,主要用到了它的inSampleSize参数,即采样率。当inSampleSize为1时,采样后的图片大小为图片的原始大小;当inSampleSize大于1时,比如2,那么采样后的图片宽高均为原图大小的1/2,像素数为原图的1/4,其占有的内存大小也为原图的1/4。
采样率必须是大于1的整数,图片才会有缩小的效果,并且采样率同时作用于宽和高,缩放比例为1/(inSampleSize的2次方),比如inSampleSize为4,那么缩放比例就是1/16。官方文档指出,inSampleSize的取值为2的指数:1、2、4、8、16等等。

(3).通过采样率可以优化加载图片,那么如何获采样率呢?

通过以下4个步骤:

  1. 将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片;
  2. 从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数;
  3. 根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize;
  4. 将BitmapFactory.Options的inJustDecodeBounds参数设为false,然后重新加载图片。

示例代码1:

//解码图片的配置选项
BitmapFactory.Options options=new BitmapFactory.Options();
//设置options里面的参数,为true,不去真实地解析Bitmap,而是查询Bitmap的宽高信息(禁为bitmap分配内存)
options.inJustDecodeBounds=true;
Bitmap bitmap= BitmapFactory.decodeFile("mnt/sdcard/image.jpg",options);
Log.e("TAG","bitmap=="+bitmap);//bitmap=null

//获取图片的宽高
int height = options.outHeight;
int width = options.outWidth;
Log.e("TAG","图片的宽度,width=="+width);
Log.e("TAG","图片的高度,width=="+height);

//获取手机屏幕的宽高,拿到窗体管理者
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
//getDefaultDisplay获取屏幕分辨率
int screenWidth=wm.getDefaultDisplay().getWidth();
int screenHeight = wm.getDefaultDisplay().getHeight();
Log.e("TAG","屏幕的宽度,width=="+screenWidth);
Log.e("TAG","屏幕的高度,width=="+screenHeight);

//计算图片和屏幕宽高的比例
int dx=width/screenWidth;
int dy=height/screenHeight;
//缩放比例
int scale=1;

//比如图片:960*480  屏幕:480*320  dx=2  dy=1.5,取dx=2,它的宽高都在屏幕里面了
//dx<1说明图片还没有屏幕高,就不需要缩放了
if (dx>dy && dy>1){
    scale=dx;
}
if (dy>dx && dx>1){
    scale=dy;
}

Log.e("TAG","scale=="+scale);

//以缩放的方式把图片加载到手机内存
options.inSampleSize=scale;
//真实地解析bitmap
options.inJustDecodeBounds=false;
Bitmap bitmap2= BitmapFactory.decodeFile("mnt/sdcard/image.jpg",options);
mIv.setImageBitmap(bitmap2);

打印结果:

image.png

这样就显示的是缩放后的图片,不会导到内存导到内存溢出。

示例代码2:

/**
 *
 * @param res
 * @param resId
 * @param reqWidth 期望图片宽(像素)
 * @param reqHeight 期望图片高(像素)
 * @return
 */
public  static Bitmap decodeSampleBitmapFromResource(Resources res,int resId,int reqWidth,int reqHeight){
    final BitmapFactory.Options options= new BitmapFactory.Options();
    options.inJustDecodeBounds=true;
    BitmapFactory.decodeResource(res,resId,options);

    //计算采样率
    options.inSampleSize=calculateInSampleSize(options,reqWidth,reqHeight);

    options.inJustDecodeBounds=false;
    return BitmapFactory.decodeResource(res,resId,options);
}

/**
 * 获取采样率
 * @param options
 * @param reqWidth
 * @param reqHeight
 * @return
 */
private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
    //获取图片的宽和高
    int width = options.outWidth;
    int height=options.outHeight;
    int inSampleSize=1;

    if (height>reqHeight ||  width>reqWidth){
        final int halfHeight=height/2;
        final int halfWidth=width/2;

        //计算最大的采样率,采样率为2的指数
        while ((halfHeight/inSampleSize)>=reqHeight && (halfHeight/inSampleSize)>=reqWidth){
            inSampleSize *=2;
        }
    }

    return inSampleSize;
}
//ImageView所期望的图片大小为150*150像素,加载显示图片
mIv.setImageBitmap(decodeSampleBitmapFromResource(getResources(),R.drawable.image,150,150));

二、图片缓存技术和图片三级缓存的实现

当需要在界面上加载大量图片的时候,比如使用ListView,GridView,或者ViewPager这样的组件,屏幕上显示的图片可以通过滑动屏幕等事件不断增加,最终导致OOM。

为了保证内存的使用始终维持在一个合理的范围,通常会把被移除屏幕的图片进行处理。这个时候,垃圾回收器也会认为你不再持有这些图片的引用,从而对这些图片进行GC操作。如果为了让程序快速地运行,在界面上迅速地加载图片,我们又需要考虑到某些图片被回收之后,用户又将它重新滑入屏幕这种情况。就需要避免又重新去加载刚刚加载过的图片,这个时候,可以使用内存缓存技术解决这个问题,它可以让组件快速地重新加载和处理图片。

内存缓存技术
内存缓存技术对那些大量占用应用程序内存的图片提供了快速访问的方法。其中最核心的类是LruCache(此类在android-support-v4的包中提供)。它的主要算法原理是把最近使用的对象用强引用存储在LinkedHashMap中,并且把最近最少使用的对象在缓存值达到预设定值之前从内存中移除。

在过去,我们经常会使用一种非常流行的内存缓存技术的实现,即软引用或弱引用 (SoftReference or WeakReference)。但是现在已经不再推荐使用这种方式了,因为从 Android 2.3 (API Level 9)开始,垃圾回收器会更倾向于回收持有软引用或弱引用的对象,这让软引用和弱引用变得不再可靠。另外,Android 3.0 (API Level 11)中,图片的数据会存储在本地的内存当中,因而无法用一种可预见的方式将其释放,这就有潜在的风险造成应用程序的内存溢出并崩溃。

为了能够选择一个合适的缓存大小给LruCache, 有以下多个因素应该放入考虑范围内,例如:

  • 你的设备可以为每个应用程序分配多大的内存?
  • 设备屏幕上一次最多能显示多少张图片?有多少图片需要进行预加载,因为有可能很快也会显示在屏幕上?
  • 你的设备的屏幕大小和分辨率分别是多少?一个超高分辨率的设备(例如 Galaxy Nexus) 比起一个较低分辨率的设备(例如 Nexus S),在持有相同数量图片的时候,需要更大的缓存空间。
  • 图片的尺寸和大小,还有每张图片会占据多少内存空间。
  • 图片被访问的频率有多高?会不会有一些图片的访问频率比其它图片要高?如果有的话,你也许应该让一些图片常驻在内存当中,或者使用多个LruCache 对象来区分不同组的图片。
  • 你能维持好数量和质量之间的平衡吗?有些时候,存储多个低像素的图片,而在后台去开线程加载高像素的图片会更加的有效。

并没有一个指定的缓存大小可以满足所有的应用程序,这是由你决定的。你应该去分析程序内存的使用情况,然后制定出一个合适的解决方案。一个太小的缓存空间,有可能造成图片频繁地被释放和重新加载,这并没有好处。而一个太大的缓存空间,则有可能还是会引起 java.lang.OutOfMemory 的异常。

(1).LruCache

一般建议采用support-v4兼容包中提供的LruCache,可兼容到早期的Android版本,目前Android2.2以下的用户量已经很少了,因此在开发的应用兼容到Android2.2就已经足够了。

介绍:

LruCache是一个泛型类,它内部采用一个LinkedHashMap以强引用的方式存储外界的缓存对象,它提供了get和put方法来完成缓存的获取和添加操作,当缓存满时,LruCache会移除较早使用的缓存对象,然后再添加新的缓存对象。

补充知识:强引用、软引用和弱引用的区别

  • 强引用
    直接的对象引用;内存不足时,JVM也不会被回收。(定义的成员变量都是强引用)
  • 软引用
    SoftReference<T> 当一个对象只有软引用存在时,系统内存不足时此对象会被gc回收;
  • 弱引用
    WeakReference<T> 当一个对象只有弱引用存在时,此对象会随时被gc回收。
  • 虚引用
    PhantomReference<T> 代码被调用的时候,就被清理了。

软引用示例代码(其他几种用法类似):

private SoftReference<ImageView> mSoftReference;

/**
 * 给imageView加载url对应的图片
 * @param iv
 * @param url
 */
public void display(ImageView iv,String url){
    mSoftReference=new SoftReference<ImageView>(iv);
    mSoftReference.clear();//这里是清除里面的iv图片资源
    //取引用--为null
    ImageView imageView = mSoftReference.get();
}

Android-->早期是davike虚拟机,Android Runtime

  1. 3.0之前,垃圾回收机制和JVM是相同的。(可以用这套软引用存储图片)
  2. 3.0之后,davike虚拟机做了升级,只要GC(回收机制)运行,SoftReference和WeakReference一律回收。(在Android中就没用了)

Android3.0之前软引用的写法(代码如下):

private static Map<String, SoftReference<Bitmap>> mCaches = new LinkedHashMap<String,SoftReference<Bitmap>>();

/**
 * 给imageView加载url对应的图片
 *
 * @param iv
 * @param url
 */
public void display(ImageView iv, String url) {
    SoftReference<Bitmap> reference = mCaches.get(url);
    if (reference==null){
        //内存中没有--》本地去取
    }else {
        Bitmap bitmap = reference.get();
        if (bitmap==null){
            //gc回收了---》本地去取
        }else {
            //内存中有,就显示
        }
    }
}

解决方案:是用了LruCache。

LruCache是线程安全的,定义如下:

public class LruCache<K,V>{
  private final LinkedHashMap<K,V> map;
  ...
}
(2).DiskLruCache

DiskLruCache用于实现本地存储缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。

(3).图片的三级缓存的实现

图片三级缓存的原理:

image.png

图片三级缓存的工具类,示例代码:

/**
 * 创建者     yangyanfei
 * 创建时间   2017/11/4 下午 06:33
 * 作用         图片三级缓存的工具类
 * <p/>
 * 版本       $$Rev$$
 * 更新者     $$Author$$
 * 更新时间   $$Date$$
 * 更新描述   ${TODO}
 */
public class ImageUtils {

    private static LruCache<String, Bitmap> mCaches;

    /**
     * 定义上下文对象
     */
    private Context mContext;

    private static Handler mHandler;

    //声明线程池,全局只有一个线程池,所有访问网络图片,只有这个池子去访问。
    private static ExecutorService mPool;

    //解决错位问题,定义一个存标记的集合
    private Map<ImageView, String> mTags = new LinkedHashMap<ImageView, String>();

    public ImageUtils(Context context) {
        this.mContext = context;
        if (mCaches == null) {
            //申请内存空间
            int maxSize = (int) (Runtime.getRuntime().freeMemory() / 4);
            //实例化LruCache
            mCaches = new LruCache<String, Bitmap>(maxSize) {
                @Override
                protected int sizeOf(String key, Bitmap value) {
                    //判断添加进入的value的占用内存的大小
                    //这里默认sizeOf是返回1,不占用,内存会不够用,所以要给它一个具体占用内存的大小
                    //                    return super.sizeOf(key, value);
                    //获取Bitmap的大小
                    return value.getRowBytes() * value.getHeight();
                }
            };
        }
        if (mHandler == null) {
            //实例化Handler
            mHandler = new Handler();
        }

        if (mPool == null) {
            //创建固定大小的线程池
            mPool = Executors.newFixedThreadPool(3);
            //创建一个缓存的线程池,生产者和消费者,一个线程生产,必须得消费完成后再生产
            /*Executors.newCachedThreadPool();
            Executors.newSingleThreadExecutor();//创建一个单线程池
            Executors.newScheduledThreadPool();//创建一个计划的任务池*/
        }
    }

    /**
     * 给imageView加载url对应的图片
     *
     * @param iv
     * @param url
     */
    public void display(ImageView iv, String url) {
        //1.从内存中获取
        Bitmap bitmap = mCaches.get(url);
        if (bitmap != null) {
            //内存中有,显示图片
            iv.setImageBitmap(bitmap);
            return;
        }

        //2.内存中没有,从本地获取
        bitmap = loadFromLocal(url);
        if (bitmap != null) {
            //本地有,显示
            iv.setImageBitmap(bitmap);
            return;
        }

        //从网络中获取
        loadFromNet(iv, url);
    }

    private void loadFromNet(ImageView iv, String url) {

        mTags.put(iv, url);//url是ImageView最新的地址

        //耗时操作
        //        new Thread(new LoadImageTask(iv, url)).start();
        //用线程池去管理
        mPool.execute(new LoadImageTask(iv, url));
        //        Future<?> submit = mPool.submit(new LoadImageTask(iv, url));
        //取消的操作(有机率取消),而使用execute没有办法取消
        //        submit.cancel(true);
    }

    private class LoadImageTask implements Runnable {
        private ImageView iv;
        private String    url;

        public LoadImageTask(ImageView iv, String url) {
            this.iv = iv;
            this.url = url;
        }

        @Override
        public void run() {
            try {
                HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();

                //连接服务器超时时间
                conn.setConnectTimeout(5000);
                conn.setReadTimeout(5000);

                //连接服务器(可写可不写)
                conn.connect();

                //获取流
                InputStream is = conn.getInputStream();

                //将流变成bitmap
                Bitmap bitmap = BitmapFactory.decodeStream(is);

                //存储到本地
                save2Local(bitmap, url);

                //存储到内存
                mCaches.put(url, bitmap);

                //在显示UI之前,拿到最新的url地址
                String recentlyUrl = mTags.get(iv);

                //把这个url和最新的url地址做一个比对,如果相同,就显示ui
                if (url.equals(recentlyUrl)) {
                    //显示到UI,当前是子线程,需要使用Handler。其中post方法是执行在主线程的
                    mHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            display(iv, url);
                        }
                    });
                }


            } catch (Exception e) {
                e.printStackTrace();
            }
        }

    }

    /**
     * 存储到本地
     *
     * @param bitmap
     * @param url
     */
    public void save2Local(Bitmap bitmap, String url) throws FileNotFoundException {
        File file = getCacheFile(url);
        FileOutputStream fos = new FileOutputStream(file);
        /**
         * 用来压缩图片大小
         * Bitmap.CompressFormat format 图像的压缩格式;
         * int quality 图像压缩率,0-100。 0 压缩100%,100意味着不压缩;
         * OutputStream stream 写入压缩数据的输出流;
         * 返回值:如果成功地把压缩数据写入输出流,则返回true。
         */
        bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos);
    }

    /**
     * 从本地获取图片
     *
     * @param url
     * @return bitmap
     */
    private Bitmap loadFromLocal(String url) {
        //本地需要存储路径
        File file = getCacheFile(url);

        if (file.exists()) {
            //本地有
            //把文件解析成Bitmap
            Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());

            //存储到内存
            mCaches.put(url, bitmap);

            return bitmap;
        }

        return null;
    }


    /**
     * 获取缓存文件路径(缓存目录)
     *
     * @return 缓存的文件
     */
    private File getCacheFile(String url) {
        //把url进行md5加密
        String name = MD5Utils.encode(url);

        //获取当前的状态,Environment是环境变量
        String state = Environment.getExternalStorageState();
        if (Environment.MEDIA_MOUNTED.equals(state)) {
            //挂载状态,sd卡存在
            File dir = new File(Environment.getExternalStorageDirectory(),
                    "/Android/data/" + mContext.getPackageName() + "/icon");
            if (!dir.exists()) {
                //文件不存在,就创建
                dir.mkdirs();
            }
            //此处的url可能会很长,一般会使用md5加密
            return new File(dir, name);
        } else {
            File dir = new File(mContext.getCacheDir(), "/icon");
            if (!dir.exists()) {
                //文件不存在,就创建
                dir.mkdirs();
            }
            return new File(dir, name);
        }
    }
}

内存存储数据:一般使用List,Map<K,V>集合。

三级缓存线程池和错位问题及处理

页面在第一次加载的时候,从网络上下载,如果滑动图片比较快,里面滑动了1000个图片,就会有1000个new Thread()线程,会造成oom,解决方案是,在代码中,不用new Thread()方法去开启线程,用线程池管理,在全局定义一个线程池,使用new FixedThreadPool()方法创建一个固定大小的线程池,一般配置里面线程个数3-5个,所有的访问网络图片,只有这个线程池去访问,(使用execute()方法).线程池的好处,比方里面设置3个线程个数,表示固定只能同时运行3个线程,其中某一个执行完成后再去执行下一个.
错位问题:
两个线程去加载图片,第二个开启的线程先到服务器,把数据拉回来了展示出来,第一个线程后到服务器把数据拉回来,结果是第二条线程返回的是第一张图片,第一条线程返回的是第二张图片,出现图片错位.出现这种情况,一般是与网络加载速度有关系.解决方案是打标记.定义一个Map集合对象,用来存标记,每次调用加载网络数据的时候就存标记,通过put()方法把ImageView和Url存进去,即使在网络加载图片的过程中,第一次调用就会存一个url,第二次调用就会存第二个url,这个url永远是ImageView最新的地址,在图片显示到UI的时候,做一个判断,拿到最新的图片url地址,把它和网络加载的图片做一个equals比较,如果相同就显示图片,如果不同就不显示,但是图片还是存在内存,存在本地,只是不显示,这样就解决了图片错位的问题.

相关知识:

java并发库---线程池(一共有四种创建线程池的方法)
//声明线程池,全局只有一个线程池,所有访问网络图片,只有这个池子去访问。
private static ExecutorService mPool;
//1.创建固定大小的线程池(用得比较多)
mPool = Executors.newFixedThreadPool(3);
//2.创建一个缓存的线程池,生产者和消费者,一个线程生产,必须得消费完成后再生产,这样有个好处,别人抢不了资源
Executors.newCachedThreadPool();
//3.创建一个单线程池
Executors.newSingleThreadExecutor();
//4.创建一个计划的任务池
Executors.newScheduledThreadPool();
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
//用线程池去管理
mPool.execute(new LoadImageTask(iv, url));
// Future<?> submit = mPool.submit(new LoadImageTask(iv, url));
//取消的操作(有机率取消),而使用execute没有办法取消
// submit.cancel(true);

三、优化列表的卡顿现象

解决这个问题的方法是:不要在主线程中做太耗时的操作,提高滑动的流畅度。
从以下三个方面来解决:

  1. 不要在getView中执行耗时操作。因为加载图片是一个耗时的操作,需要通过异步的方式来处理。
  2. 控制异步任务的执行频率。比如用户频繁上下滑动,就会产生上百个异步任务,这些异步任务会造成线程池的拥堵并且一瞬间存在大量的UI更新操作,这些UI更新操作是运行在主线程的,就会造成一定程度的卡顿。那么如何解决这个问题呢?可以在列表滑动的时候停止加载图片,当列表不滑动的时候加载图片,就能获得良好的用户体验。具体实现:可以给ListView或者GridView设置setOnScrollListener,并在OnScrollListener的onScrollStateChanged方法中判断列表是否处于滑动状态,如果是的话就停止加载图片,如下:
public void onScrollStateChanged(AbsListView view,int scrollState){
  if(scrollState==OnScrollListener.SCROLL_STATE_IDLE){
    mIsGridViewIdle=true;
    mImageAdapter.notifyDataSetChanged();
  }else{
    mIsGridViewIdle=false;
  }
}

然后在getView方法中,仅当列表静止时才能加载图片,如下:

if(mIsGridViewIdle && mCanGetBitmapFromNetWork){
  imageView.setTag(uri);
  mImageLoader.bindBitmap(uri,imageView,mImageWidth,mImageHeight);
}

经过上面两个步骤,列表一般就不会出现卡顿现象了。但在有些特殊情况下,需要开启硬件加速,通过设置 android:hardwareAccelerated="true"即可为Activity开启硬件加速。

在实际程序中灵活运用上述技巧来避免OOM:Android照片墙应用实现|SquirrelNote

以上是根据我的一些理解,做的总结分享,旨在抛砖引玉,希望有更多的志同道合的朋友一起讨论学习,共同进步!

参考文献:
http://blog.csdn.net/guolin_blog/article/details/9316683
http://blog.csdn.net/guolin_blog/article/details/9526203

推荐阅读更多精彩内容