Android 4.x Gallery2分析

Gallery2介绍

Gallery2主要功能是实现Android系统本地存储以及网络存储中的媒体(图片&视频)资源的浏览,媒体信息,显示和更多操作(删除、分享、选择、缩放、编辑等)。

Gallery2界面的生成和普通的应用程序不同,普通应用程序一般就一个界面对应一个activity,搭配布局xml或代码来实现界面的显示,而Gallery2没有用到androidUI系统,而是通过openGL画出来,只用到了一个acitivity.

Android4.x后的Gallery2的主要组成有:

①AlbumSetPage  所有专辑界面,点击Gallery图标后进入图库所显示的第一个页面;

②AlbumPage 单个专辑界面,AlbumSetPage的一个子集,显示该专辑内的所有图片,专辑通过文件夹来区分;

③PhotoPage 单张图片界面,可用通过AlbumPage进入,也可以通过外部调用浏览单张图片。

   

 基本数据

Gallery2中描述单个媒体对象的数据结构图如下:



Gallery2中描述一组媒体对象的数据结构图如下:



MediaObject 数据渲染的最小单位,它包含丰富的衍生类。MediaObject定义了媒体数据最基本的信息,如SupportedOperationsSupportedOperations定义这个媒体文件支持的操作,如是否可以delete/share/rotate等。定义了最基本的Path路径,用于表示媒体对象的存储地址;

MediaItem MediaObject的衍生类,MediaObject的封装,是单个媒体的抽象,代表一张图片或者一个视频。在此抽象类中,定义getMimeType()/getWitdh()/getHeight()等抽象方法。;

LocalMediaItem MediaItem的衍生类,对本地MediaItem的抽象,代表一张本地图片或者一个本地视频。在此抽象类中,添加定义了bucketId/dataDirty; bucketId由文件夹的绝对路径的hashCode来表示,代表一个专辑,是专辑的索引。通过GalleryUtilsgetBucketId可以获得传入路径的bucketId

LocalImage LocalMediaItem的子类,表示一个本地存储的图片。内部定义了一个ITEM_PATH="/local/image/item"。首先LocalImage的初始化有两种,一种是通过直接传入cursor对象来初始化这个Image对象,另外一种是通过传入id的形式来查询外部存储的数据库,得到cursor,进而初始化这个Image对象。

MediaSet MediaItem一样是MediaObject的衍生类,是一个类目录的数据结构,是一组媒体文件的抽象。它提供的主要基础接口有getMediaItemCount, getMediaItem, getSubMediaSetCount, getSubMediaSet, getTotalMediaItemCount. 还定义了getCoverMediaItem来获得一组图片或视频的封面。

LocalAlbumSet 继承于MediaSet,是所有图片和视频专辑的集合。其内部定义了三个path,分别是PATH_ALL,PATH_IMAGE,PATH_VIDEO.其内部定义了mAlbums用来保存专辑列表。LocalAlbumSetAlbumSetPage的单位。

LocalAlbum 继承于MediaSet,代表一个bucket(目录)下的所有的media items。提供MediaItem的查询,删除等操作。LocalAlbumAlbumPage的单位。



 数据源

Gallery2中引入数据源的概念,由DataManager负责管理,目的是在不同的显示界面,能通过DataManager获得一个合适的数据源来初始化自己的数据。Gallery2中主要定义的数据源有ComboSource(组合源), PicasaSourcePicasa源),LocalSource(本地源), ClusterSource(簇源), UriSourceURL源),FilterSource(过滤源)。这些数据源有一个共同的基类MediaSource, MediaSource是对数据源的抽象,它里面主要定义了数据源的基本组成,如定义了数据源的唯一标识prefix, prefix后面会讲到。


 DataManager数据管理

在详细分析数据源的组成结构之前,首先来分析下DataManagerDataManager是用来管理整个系统中的所有media sets(集合)和media itemDataManagerGallery Application启动时就创建并且初始化,可以通过GalleryAppImplgetDataManager方法来获得DataManager实例,DataManager的初始化做了以下事情:

addSource(new LocalSource(mApplication));

addSource(new PicasaSource(mApplication));

addSource(new ComboSource(mApplication));

addSource(new ClusterSource(mApplication));

addSource(new FilterSource(mApplication));    

addSource(new UriSource(mApplication));

可以看出,DataManager实例化的同时也创建了所有数据源实例,并把它们加入自身维护的一个SourceMap中,提供存取操作。SourceMap中保存的索引是上面讲到的prefix。Prefix是数据源的唯一标识,在数据源的构造方法中赋值。如LocalSourceprefix”local” ComboSourceprefix”combo”。

    void addSource(MediaSource source) {

        if (source == null) return;

        mSourceMap.put(source.getPrefix(), source);

}

DataManager不仅提供了丰富的数据操作接口,同时定义了一组代表数据集合的PATH

TOP_SET_PATH = "/combo/{/local/all,/picasa/all}";  //表示用户能看到的最顶端的数据集合

TOP_IMAGE_SET_PATH = "/combo/{/local/image,/picasa/image}";  //表示用户能看到的最顶端的图片数据集合

TOP_VIDEO_SET_PATH = "/combo/{/local/video,/picasa/video}";  //表示用户能看到的最顶端的视频数据集合

TOP_LOCAL_SET_PATH = "/local/all";  //表示用户能看到的最顶端的本地数据集合

TOP_LOCAL_IMAGE_SET_PATH = "/local/image";  //表示用户能看到的最顶端的本地图片集合

TOP_LOCAL_VIDEO_SET_PATH = "/local/video";  //表示用户能看到的最顶端的本地视频集合

数据范围的比较如下:

TOP_SET_PATH > String TOP_IMAGE_SET_PATH = TOP_VIDEO_SET_PATH > TOP_LOCAL_SET_PATH > TOP_LOCAL_IMAGE_SET_PATH = TOP_LOCAL_VIDEO_SET_PATH

//插入一张DataManager的一张类结构图



LocalSource本地数据源

LocalSource表示本地存储器中的所有Media数据源,负责管理Local Media数据集。从它的createMediaObject方法(继承于MediaSource)可以看出,它可以根据传入的path路径,创建出LocalAlbumSet,LocalAlbum,LocalMergeAlbum,LocalImage,LocalVideo所有本地媒体数据相关的数据集合以及单个媒体文件。

    public MediaObject createMediaObject(Path path) {

        GalleryApp app = mApplication;

        switch (mMatcher.match(path)) {

            case LOCAL_ALL_ALBUMSET:

            case LOCAL_IMAGE_ALBUMSET:

            case LOCAL_VIDEO_ALBUMSET:

                return new LocalAlbumSet(path, mApplication);

            case LOCAL_IMAGE_ALBUM:

                return new LocalAlbum(path, app, mMatcher.getIntVar(0), true);

            case LOCAL_VIDEO_ALBUM:

                return new LocalAlbum(path, app, mMatcher.getIntVar(0), false);

            case LOCAL_IMAGE_ITEM:

                return new LocalImage(path, mApplication, mMatcher.getIntVar(0));

            case LOCAL_VIDEO_ITEM:

                return new LocalVideo(path, mApplication, mMatcher.getIntVar(0));

            default:

                throw new RuntimeException("bad path: " + path);

        }

}

那么LocalSource是怎样根据传入的path来生成AlbumSet,还是Album呢?首先我们先来看看LocalSource的构造方法:

    public LocalSource(GalleryApp context) {

        super("local");

        mApplication = context;

        mMatcher = new PathMatcher();

        mMatcher.add("/local/image", LOCAL_IMAGE_ALBUMSET);

        mMatcher.add("/local/video", LOCAL_VIDEO_ALBUMSET);

        mMatcher.add("/local/all", LOCAL_ALL_ALBUMSET);

        mMatcher.add("/local/image/*", LOCAL_IMAGE_ALBUM);

        mMatcher.add("/local/video/*", LOCAL_VIDEO_ALBUM);

        mMatcher.add("/local/all/*", LOCAL_ALL_ALBUM);

        mMatcher.add("/local/image/item/*", LOCAL_IMAGE_ITEM);

        mMatcher.add("/local/video/item/*", LOCAL_VIDEO_ITEM);

}

LocalSource的构造方法中实例化了PathMatcher,并将所有代表local资源相关的path及其类型添加到PathMatcher实例中。这里PathMatcher的作用是维护一个树结构,用于保存path以及匹配path类型。PathMatcher类内部定义一个Node(节点),代表树的一个节点。NodeHashMap以及一个整型kind组成,其中HashMap用来保存路径子段和Node的映射,而整型kind用来保存该节点的类型,如(LOCAL_IMAGE_ALBUMSET/LOCAL_VIDEO_ALBUMSET)等。先来说一下PathMatcher的实现过程,在PathMatcher的构造方法中,首先创建了一个名为Root的树的根节点,这个Root的根节点作为match操作的入口。另外,PatchMatcher通过add方法,先将传入的path路径以”/”为分割符创建segments数组,然后通过segments数组的元素构造树结构,并给最后一个节点的kind类型赋值,表示从根节点到该节点生成的path代表哪个类型的媒体结构。



匹配的过程如下:

path = “/local/image/item/10001”

序列为:

[local][image][item][10001]

二叉树查询:

Kind=LOCAL_IMAGE_ITEM, 生成LocalImage, id=10001   



 Media数据的加载过程

我们从点击Gallery2图标进入图片专辑页面这个过程为例,描述一下Local数据的加载过程。

首先点击图库图标进入GalleryActivity(旧版本或者命名为Gallery),这个Activity是整个图库程序的入口,非外部ACTION_VIEW调用下,调用startDefaultPage启动AlbumSetPage(就是我们打开Gallery2后见到的第一个专辑页面),这时传入给AlbumSetPage一个名为media-path的参数,media-path值为"/combo/{/local/all,/picasa/all}",这个是一个combo类型的path,表示需要显示local以及picasa两个组合的所有的媒体文件,具体的解析步骤如下:

1)在AlbumSetPageinitializeData方法取出media-pathmediaPath = /combo/{/local/all,/picasa/all}

2AlbumSetPage通过DataManager实例解析出由两个segments组成的url

 Segments[0]:combo

Segments[1]:{/local/all,/picasa/all}

3)第二部解析出来pathprefix(前缀)是bomboDataManager通过这个prefix取得对应的数据源,这里获得的数据源是ComboSource

4DataManager调用ComboSourcecreateMediaObject方法来初始化ComboAlbumSet实例返回到AlbumSetPage,与此同时,在构造ComboAlbumSet时,继续分拆大括号里的/local/all/picasa/all,生成LocalSource实例和EmptySource实例。

4.1)分拆/local/all,创建LocalSource数据源,生成LocalAlbumSet实例。

Segments[0]:local

Segments[1]:all

current path=/local/all Prefix=local

4.2)分拆/picasa/all,创建PicasaSource数据源,生成EmptyAlbumSet实例

  Segments[0]:picasa

Segments[1]:all

current path=/picasa/all Prefix=picasa

1~4)是AlbumSetPageinitializeData调用获取mMediaSet所做的事情,这里返回的mMediaSet实例就是指向一个由ComboSource数据源创建的ComboAlbumSet实例,后面这个mMediaSet的所有实现都可以在ComboAlbumSet中找到。获得mMediaSet后,initializeData里继续做的事情有,用mMediaSet初始化mSelectionManager来管理这个界面的所有选择操作;用mMediaSet初始化AlbumSetDataLoader实例,作为数据适配器,数据取自mMediaSet,这里生成的实例名叫mAlbumSetDataAdapter,也很形象,数据源和适配器都准备完毕后,mAlbumSetView通过setMode方法将适配器传入渲染器中。



 Gallery2页面结构

Android 4.xGallery2主要由AlbumSetPage, AlbumPage, PhotoPage三个页面组成,它们都继承自ActivityStateActivityState会在下面描述,实际上可以把ActivityState看作是Gallery2三个页面中的其中一个Activity状态,也可以理解成当前Activity以哪种界面显示。 一般我们见到的程序窗口,大多是用Actiivty来实现的,而Gallery2中,实际上只有一个Activity,叫AbstractGalleryActivityGallery2通过ActivityStateStateManager来实现三个页面在同一个Activity中显示和切换。

在讲三个主要界面之前,先来了解一下Gallery2这个特殊的显示结构。


ActivityState

Gallery2中定义了ActivityState这样一个抽象类,表示一个页面的状态,或者可以理解成在Galelry2中,这就是一个界面。每一个子页面(如AlbumSetPage)都是ActivityState的衍生类。

ActivityState有一整套类似Activity一样的生命周期,整个生命周期全部由StateManager来管理。

ActivityState的结构和Activity十分的类似,可以说是一个精简的Activity。和StartActivity来启动一个Activity不同,ActivityState通过StateManagerstartState方法来开启,整个生命周期的管理由StateManager来管理。


① ActivityStateActivity的类结构对比


 StateManager

StateManager的作用类似于ActivityManager,是每个子页面(ActivityState)的管理类,负责子页面的切换,命令的执行,页面的刷新显示等职能,是ActivtyState的调度器。Gallery将所有页面操作命令交给StateManager来执行,StateManager再将命令分发给当前的子页面执行。

StateManger可以通过AbstractGalleryActivitygetStateManager方法获得。StateManager中包含一个堆栈mStack,用于保存ActivtyState,和Activity一样的实现方式,子页面先进后出,当切换到新的子页面时,当前页面压入堆栈中。当用户按back返回时,当前页面出栈并销毁,前一个子页面做为当前显示界面。



StateManager是一个桥梁,将真正的ActiivtyGalleryActivity)和ActivityState状态关联起来。当启动Gallery2时,首先执行GalleryActivityonCreate方法,然后调用StateManagerstartState方法加载AlbumSetPageStateManagerstartState方法中,首先

会判断当前堆栈栈顶是否为空,由于AlbumSetPage是第一页面,此时堆栈为空,因此AlbumSetPage会被压入堆栈中,并在栈顶显示。在将AlbumSetPage压入栈后,会依次执行AlbumSetPageonCreateonResume方法。这样一来给人的感觉就是,我启动了AlbumSetPage,同样会执行onCreateonResume方法,从ActiivtyActivityState状态的同步是通过StateManager来传递的(图9)。

同时SateManager也是ActivityState之间切换的桥梁,举个例子:从AlbumSetPage进入AlbumPage的过程中,在AlbumSetPage中通过调用SateManagerstartStateForResult(类似于StartActivityForResult,含返回值)方法切换到AlbumPage,此时AlbumSetPage会被压入堆栈,执行onPause方法。并建立AlbumPage并将其进栈,执行onCreate, onResume方法,最终AlbumPage在栈顶显示。当用户执行back事件后跳回AlbumSetPage页面,AlbumPage出栈并执行onStoponDestory方法。

 

                         ActivityActivityState状态的传递过程


 GalleryAppImpl

GalleryAppImpl继承于Application并实现了GalleryApp接口,Gallery2应用程序启动后首先执行GalleryAppImplGalleryApp接口抽象了Gallery2作为图库所要实现的最基本操作,例如getDataManager获取数据管理器加载图片视频数据,getImageCacheService获取图片缓存服务,getDownloadCache获取下载缓存,getThreadPool获取线程池等。

GalleryAppImplonCreate方法主要执行GalleryUtils工具类的initialize初始化方法来获得手机屏幕的分辨率大小等信息。


 AbstractGalleryActivity

AbstractGalleryAcitivtyGallery2最重要的一个ActivityGallery2只有一个Actiivty用于显示,它就是AbstractGalleryActivity,它的派生类GalleryActivity是程序的主入口。AbstractGalleryActivity实现了GalleryContext接口,包括StateManager的初始化,在StateManager的初始化中传入AbstractGalleryActiivty的对象引用,这样就StateManager以及它的所有子类都可以通过该引用调用AbstractGalleryActivity提供的方法。

同时AbstractGalleryActivity提供很多实用方法,如调用getDataManager得到DataManager实例,通过getGLRoot方法获得GLRootView,通过getGalleryActionBar方法获得GalleryActionBar等。


 GalleryActivity

GalleryActivityAbstractGalleryActivity的衍生类,是Gallery2程序的主入口,在onCreate方法中initializeByIntent来判断调用的方式,调用方式包括ACTION_GET_CONTENT,ACTION_PICK,ACTION_VIEWdefault,根据不同的ACTION执行正确的Gallery调用。如在MMS中调用Gallery2添加图片资源,MMS会发出ACTION_PICK广播进入Gallery2选择图片。

如果ACTION不属于以上(直接点击程序图标进入),GalleryActivity执行startDefaultPage方法,该方法调用StateManagerstartState方法进入AlbumSetPage,并传入的KEY_MEDIA_PATH值为INCLUDE_ALL,表示在AlbumSetPage中查看所有media


 AlbumSetPage数据加载和渲染过程分析

AlbumSetPage是我们进入Gallery2后的第一个页面,作用是显示所有相册专辑。其结构主要由一个SlotViewSlot组成(如下图)。一个Slot代表一个相册专辑。Slot中包含的内容有:一张封面图片(一般是相册中首张图片资源),相册标题,相册大小等,如果这是一个特殊的相册,例如是一个保存Camera拍照后的相册,在相册封面上还会有Camera图标表示。


AlbumSetPage 


第一步 initializeViews,目的是初始化 SlotView 以及其渲染器 mAlbumSetView。 首先初始化 mSelectionManager,用于管理选择事件;

初始化 mConfig 获得有关 AlbumSetPage 的运行参数,包括 Slot 的组成元素的颜色,初始化每个 Slot 在 SlotView 中的大小,间隔等 参数;

初始化 AlbumSetSlotRenderer,SlotView 的渲染器,实例名为 mAlbumSetView;

根据 mConfig 和 AlbumSetSlotRenderer 初始化 mSlotView,并给 mSlotView 注册事件监听; 初始化 mActionModeHandler 处理 ActionBar 事件;

最终将 SlotView 添加到 RootPane 中;




第二步   initializeData,目的是实例化数据适配器,并为渲染器提供数据接口。

获得 GalleryActivity 传过来的 mediaPath,并通过 DataManager 的 getMediaSet 方法获得对应的数据源,由于传过来的 mediaPath 为

/combo/{/local/all,/picasa/all},因此得到的是一个 ComboSource 数据源,此数据源另外包含一个 LocalSource 和 EmptySource,后面这 个 mMediaSet 所有的实现都可以在 ComboAlbumSet 中找到;

根据获得的数据源(mediaSet)来实例化名为 mAlbumSetDataAdapter 的 AlbumSetDataLoader 对象,作为数据适配器。数据源和适配 器都准备完毕后,调用 setMode 方法将数据适配器添加到 SlotView 的渲染器 mAlbumSetView 中;

第三步 调用数据适配器加载媒体数据,为后面渲染提供数据支持。

AlbumSetPage 的 onCreate 方法执行完后,在随后执行 onResume 方法中,首先调用数据适配器(AlbumSetDataLoader)的 resume 方 法开始执行数据加载操作。AlbumSetDataLoader 开启一个 ReloadTask 执行数据源 reload 工作。通过层层的 reload,并调用 BucketHelper 查询 MediaProvider 数据库,获得专辑数据并返回。

ReloadTask 的主要功能是循环调用数据源的 reload 方法,进而更新获得专辑数据,一旦获得专辑数量变化后,通过监听器回调的 方式通知 SlotView,告知专辑数,创建相应数量的 slot,这也是我们平时看到的专辑。



数据加载流程图


第四步 获得 AlbumSet 数据后,渲染器开始渲染 SlotView

数据适配器 AlbumSetDataLoader 中的 ReloadTask 执行完后,发送 MSG_LOAD_FINISH 广播通知到 AlbumSetPage 更新界面,并完成

SlotView 渲染前的准备工作,流程如下:



ThreadPool 线程池

线程池的基本思想是一种对象池的思想,系统开辟一块内存空间,里面存放着众多(未死亡)的线程,池中线程的执行调度由池 管理器来处理。当有线程任务时,池管理器会从池中取一个,执行完成后线程对象归池,这样就可以避免反复创建线程对象所带来的 性能开销了,从而节省了系统资源。

用线程池来管理的好处是,可以保证系统稳定性,适用于有大量线程,高工作量的情景下使用,假如现在我们有 2000 张图片资源

加载并显示,如果创建 2000 个线程区加载执行,系统肯定会死掉,线程池就可以避免这个问题,方案是可以用 5 个线程轮流执行,5 个为一组,执行完成后不直接回收,而是等待下次执行,这样对系统的开销就可以大大减少。

Executor 是 Java 工具类,执行提交给它的 Runnable 任务。该接口提供了一种基于任务运行机制的任务提交方法,包括线程使用详 细信息,时序等等 。 Executor 通常用于替代创建多线程。例 如 :你可能会使用以 下 方式来代替创建线 程 集合中的线程

new Thread(new(RunnableTask())).start()。
Executor executor = anExecutor; executor.execute(new RunnableTask1()); executor.execute(new RunnableTask2());
...

不过,Executor 接口并没有严格地要求执行是异步的。在最简单的情况下,执行程序可以在调用者的线程中立即运行已提交的任务:

class DirectExecutor implements Executor {


public void execute(Runnable r) { r.run();
}
}

更常见的是,任务是在某个不是调用者线程的线程中执行的。以下执行程序将为每个任务生成一个新线程。

class ThreadPerTaskExecutor implements Executor { public void execute(Runnable r) {
new Thread(r).start();

}
}

此包中提供的 Executor 实现实现了 ExecutorService,这是一个使用更广泛的接口。ThreadPoolExecutor类提供一个可扩展的 线程池实现。Executors 类为这些 Executor  提供了便捷的工厂方法。

Java 里面线程池的顶级接口是 Executor,但是严格意义上讲 Executor 并不是一个线程池,而只是一个执行线程的工具。真正的线

程池接口是 ExecutorService。

根据线程池的执行策略,Executor 的 execute()可能在新线程中执行,或者在线程池中的某个线程中执行,也可能是在调用者线程 中执行。ExecutorService 在 Executor 的基础上增加了两个核心方法:

1、Future<?> submit(Runnable task) 

2、<T> Future<T> submit(Callable<T> task)

差异点:这两个方法都可以向线程池提交任务,区别在于 Runnable 执行完 run()有返回值,而 Callable 执行完 call()后有返回值。 共同点:submit 都返回 Future 对象,Future 对象可以阻塞线程直到运行完毕,也可以取消任务执行和检测任务是否执行完毕。 在 executors 类里面提供了一些静态工厂,生成一些常用的线程池: 1、newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任

务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执 行。

2、newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池 的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。

3、newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲

(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制, 线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。

4、newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。 

5、newSingleThreadExecutor:创建一个单线程的线程池。此线程池支持定时以及周期性执行任务的需求。



下面再介绍下 ThreadPoolExecutor 函数,以便对线程池有进一步认识:

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue,

RejectedExecutionHandler handler);

corePoolSize: 线程池维护线程的最少数量 

maximumPoolSize:线程池维护线程的最大数量 

keepAliveTime: 线程池维护线程所允许的空闲时间

unit: 线程池维护线程所允许的空闲时间的单位 

workQueue: 线程池所使用的缓冲队列

handler: 线程池对拒绝任务的处理策略

当一个任务通过 execute(Runnable)方法欲添加到线程池时:

1、如果此时线程池中的数量小于 corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。 

2、如果此时线程池中的数量等于 corePoolSize,但是缓冲队列 workQueue 未满,那么任务被放入缓冲队列。 

3、如果此时线程池中的数量大于 corePoolSize,缓冲队列 workQueue 满,并且线程池中的数量小于 maximumPoolSize,建新的线

程来处理被添加的任务。

4、如果此时线程池中的数量大于 corePoolSize,缓冲队列 workQueue 满,并且线程池中的数量等于 maximumPoolSize,那么通过




猜你喜欢

转载自blog.csdn.net/lgglkk/article/details/54918441