一、序
我之前发布了个图片加载结构,在JCenter关闭后,“闭关修炼”,想着改好了出个2.0版本。
后来觉得仅添加功用和改善完结不行,得弥补一下用例。
相册列表的加载便是很好的用例,然后在Github找了一圈,没有找到满意的,有的乃至好几年没保护了,于是就自己写了一个。
代码链接:github.com/BillyWei01/…
比较于图片加载,相册加载在Github上要多许多。
其原因大概是图片加载的input/output比较规范,不触及UI布局;
而相册则不然,几乎每个APP都会有自己共同的需求,有自己的UI风格。
因而,相册库很难做到通用于大部分APP。
我所完结的这个也相同,并非以完结通用的相册组件为目的,而是作为一个样例,以供参阅。
二、 需求描述
网上不少相册的开源库,都是照微信相册来搭的界面,我也是跟着这么做吧,要是说触及侵权什么的,那些长辈应该先比我收到通知……
主要是自己也不会UI设计,不找个参照目标怕完结的太丑陋。
话说回来,要是真的触及侵权,请联络我处理。
相册所要完结的功用,概括来说,便是显现相册列表,点击缩略图选中,点击完结结束挑选,回来挑选成果。
需求细节,包含但不限于以下列表:
- 完结目录列表,相册列表,预览页面;
- 支撑单选/多选;
- 支撑显现挑选次序和限定挑选数量;
- 支撑自界说挑选条件;
- 支撑自界说目录排序;
- 支撑“原图”选项;
- 支撑再次进入相册时传入现已选中的图片/视频;
- 支撑切换出APP外摄影或删去相片后,回到相册时主动改写;
作用如图:
三、API设计
由于不同的页面或许需求不相同,所以能够将需求参数封装到”Request“中;
关于通用的选项,以及相册组件的全局装备,能够更封装到“Config”中。
而Request/Config最好是用链式API去设置参数,链式API尤其合适参数是“可选项”的场景。
3.1 全局设置
EasyAlbum.config()
.setImageLoader(GlideImageLoader)
.setDefaultFolderComparator { o1, o2 -> o1.name.compareTo(o2.name)}
GlideImageLoader是相册组件界说的ImageLoader接口的完结类。
public interface ImageLoader {
void loadPreview(MediaData data, ImageView imageView, boolean asBitmap);
void loadThumbnail(MediaData data, ImageView imageView, boolean asBitmap);
}
不同的APP运用的图片加载结构不相同,所以相册组件最好不要强依靠图片加载结构,而是暴露接口给调用者。
当然,关于整个APP而言,不建议界说这样的ImageLoader类,由于APP运用图片加载的当地许多,
界说这样的类,要么需求重载许多办法,要么便是参数列表很长,也就丧失链式API的优点了。
关于目录排序,EasyAlbum中界说的默许排序是按照更新时刻(取最新的图片的更新时刻)排序。
上面代码举例的是按目录名排序。
假如需求某个目录排在列表前面,能够这样界说(以“Camera”为例):
private val priorityFolderComparator = Comparator<Folder> { o1, o2 ->
val priorityFolder = "Camera"
if (o1.name == priorityFolder) -1
else if (o2.name == priorityFolder) 1
else o1.name.compareTo(o2.name)
}
出个思考题:
假如需求“优先排序”的不只一个目录,比方期望“Camera”榜首优先,”Screenshots”第二优先,“Pictures”第三优先……
改如何界说Comparator?
3.2 发动相册
EasyAlbum发动相册以from起头,以start结束。
EasyAlbum.from(this)
.setFilter(TestMediaFilter(option))
.setSelectedLimit(selectLimit)
.setOverLimitCallback(overLimitCallback)
.setSelectedList(mediaAdapter?.getData())
.setAllString(option.text)
.enableOriginal()
.start { result ->
mediaAdapter?.setData(result.selectedList)
}
具体到完结,便是from回来 Request, Request的start办法发动相册页(AlbumActivity)。
public class EasyAlbum {
public static AlbumRequest from(@NonNull Context context) {
return new AlbumRequest(context);
}
}
public final class AlbumRequest {
private WeakReference<Context> contextRef;
AlbumRequest(Context context) {
this.contextRef = new WeakReference<>(context);
}
// ...其他参数..
public void start(ResultCallback callback) {
Session.init(this, callback, selectedList);
if (contextRef != null) {
Context context = contextRef.get();
if (context != null) {
context.startActivity(new Intent(context, AlbumActivity.class));
}
contextRef = null;
}
}
}
发动AlbumActivity,就触及传参和成果回来。
有两种思路:
- 经过intent传参数到AlbumActivity, 用startActivityForResult发动,经过onActivityResult接纳。
- 经过静态变量传递参数,经过Callback回调成果。
榜首种办法,需求一切的参数都能放入Intent, 基础数据能够传,自界说数据类能够完结Parcelable,
但那关于接口的完结,就没办法放 intent 了,到头来仍是要走静态变量。
因而,爽性就都走静态变量传递好了。
这个计划可行的前提是, AlbumActivity是封闭的,不会在跳转其他Activity。
在这个前提下,App不会同一个时刻翻开多个AlbumActivity,不需求担心同享变量彼此搅扰的情况。
然后便是,在Activity结束时,做好整理作业。
能够将“发动相册-挑选图片-结束相册”笼统为一次“Session”, 在相册结束时,执行一下clear操作。
final class Session {
static AlbumRequest request;
static AlbumResult result;
private static ResultCallback resultCallback;
static void init(AlbumRequest req, ResultCallback callback, List<MediaData> selectedList) {
request = req;
resultCallback = callback;
result = new AlbumResult();
if (selectedList != null) {
result.selectedList.addAll(selectedList);
}
}
static void clear() {
if (request != null) {
request.clear();
request = null;
resultCallback = null;
result = null;
}
}
}
四、媒体文件加载
媒体文件加载似乎很简略,就调ContentResolver query一下的事,但要做到尽量完备,需求考虑的细节仍是不少的。
4.1 MediaStore API
查询媒体数据库,需走ContentResolver的qurey办法:
public final Cursor query(
Uri uri,
String[] projection,
String selection,
String[] selectionArgs,
String sortOrder,
CancellationSignal cancellationSignal) {
}
媒体数据库记载了各种媒体类型,要过滤其间的“图片”和“视频”,有两种办法:
1、用SDK界说好的MediaStore.Video和MediaStore.Images的Uri。
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2、直接读取”content://external”, 经过MEDIA_TYPE字段过滤。
private static final Uri CONTENT_URI = MediaStore.Files.getContentUri("external");
private static final String TYPE_SELECTION = "(" + MediaStore.Files.FileColumns.MEDIA_TYPE + "="
+ MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
+ " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "="
+ MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
+ ")";
假如需求同时读取图片和视频,第2种办法更省劲一些。
至于查询的字段,视需求而定。
以下是比较常见的字段:
private static final String[] PROJECTIONS = new String[]{
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
MediaStore.Files.FileColumns.MEDIA_TYPE,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.Video.Media.DURATION,
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.Images.Media.ORIENTATION
};
DURATION, SIZE, WIDTH, HEIGHT,ORIENTATION等字段有或许是无效的(0或许null),
假如是无效的,能够去从文件自身获取,但读文件比较耗时,
所以能够先尝试从MediaStore读取,毕竟是都访问到这条记载了,从空间局部原理来说,读取这些字段是趁便的事情,价值要比另外读文件自身低许多。
当然,假如确实不需求这些信息,能够直接不读取。
4.2 数据包装
数据查询出来,需求界说Entity来包装数据。
public final class MediaData implements Comparable<MediaData> {
private static final String BASE_VIDEO_URI = "content://media/external/video/media/";
private static final String BASE_IMAGE_URI = "content://media/external/images/media/";
static final byte ROTATE_UNKNOWN = -1;
static final byte ROTATE_NO = 0;
static final byte ROTATE_YES = 1;
public final boolean isVideo;
public final int mediaId;
public final String parent;
public final String name;
public final long modifiedTime; // in seconds
public String mime;
long fileSize;
int duration;
int width;
int height;
byte rotate = ROTATE_UNKNOWN;
public String getPath() {
return parent + name;
}
public Uri getUri() {
String baseUri = isVideo ? BASE_VIDEO_URI : BASE_IMAGE_URI;
return Uri.parse(baseUri + mediaId);
}
public int getRealWidth() {
if (rotate == ROTATE_UNKNOWN || width == 0 || height == 0) {
fillData();
}
return rotate != ROTATE_YES ? width : height;
}
public int getRealHeight() {
if (rotate == ROTATE_UNKNOWN || width == 0 || height == 0) {
fillData();
}
return rotate != ROTATE_YES ? height : width;
}
// ......
}
4.2.1 数据同享
字段的界说中,没有直接界说path字段,而是界说了parent和name,由于图片/视频文件或许有成千上万个,可是目录大概率不会超越3位数,所以,咱们能够经过复用parent来节省内存。
同理,mime也能够复用。
截取部分查询的代码:
int count = cursor.getCount();
List<MediaData> list = new ArrayList<>(count);
while (cursor.moveToNext()) {
String path = cursor.getString(IDX_DATA);
String parent = parentPool.getOrAdd(Utils.getParentPath(path));
String name = Utils.getFileName(path);
String mime = mimePool.getOrAdd(cursor.getString(IDX_MIME_TYPE));
// ......
}
复用字符串,能够用HashMap来做,我这边是模仿HashMap写了一个专用的类来完结。
getOrAdd办法:传入一个字符串,假如容器中现已有这个字符串,回来容器保存的字符串,
不然,保存当时字符串并回来。
如此,一切的MediaData共用相同parent和mime字符串目标。
4.2.2 处理无效数据
前面说到,从MediaStore读取的数据,有部分是无效的。
这些或许无效的字段不要直接public, 而是供给get办法,并在回来之前检查数据的有效性,假如数据无效则读文件获取数据。
当然,读文件是耗时操作,尽管一般情况下时刻是可控的,可是最好仍是放IO线程去访问比较稳妥。
也有比较折中的做法:
- 数据仅仅用作参阅,有的话更好,没有也不要紧。
假如是这样的话,供给不做检查直接回来数据的办法:
public int getWidth() {
return rotate != ROTATE_YES ? width : height;
}
public int getHeight() {
return rotate != ROTATE_YES ? height : width;
}
- 数据比较重要,但也不至于没有就不行。
这种case,当数据无效时,能够先尝试读取,可是加个timeout, 在规定时刻内没有完结读取则直接回来。
public int getDuration() {
if (isVideo && duration == 0) {
checkData();
}
return duration;
}
void checkData() {
if (!hadFillData) {
FutureTask<Boolean> future = new FutureTask<>(this::fillData);
try {
// Limit the time for filling extra info, in case of ANR.
AlbumConfig.getExecutor().execute(future);
future.get(300, TimeUnit.MILLISECONDS);
} catch (Throwable ignore) {
}
}
}
4.3 数据加载
数据加载部分是最影响相册体会的因素之一。
等待时刻、数据改写,数据有效性等都会影响相册的交互。
4.3.1 缓存MediaData
媒体库查询是一个综合IO读取和CPU密集核算的操作,文件少的时分还好,一旦文件比较多,耗时几秒钟也是有的。
假如用户每次翻开相册都要等几秒钟才刷出数据,那体会就太糟糕了。
加个MediaData的缓存,再次进入相册时,就不需求再次读一切字段了,
只需读取MediaStore的ID字段,然后结合缓存,做下Diff, 已删去的移除出缓存,新增的依据ID检索其记载,创立MediaData添加到缓存。
再次进入相册,即使有增删也不会太多。
缓存MediaData的好处不仅仅是加快再次查询MediaStore,还能够减少目标的创立,不需求每次查询都从头创立MediaData目标;
另外,前面也说到,MediaData部分字段有的是无效的,在无效时需求读取原文件获取,缓存MediaData可免除再次读文件获取数据的时刻(假如目标是读取MediaStore从头创立的,就又回到无效的状态了)。
还有便是,有缓存的话,就能够做预加载了。
当然这个得看APP是否有这个需求,假如APP是媒体相关的,大概率要访问相册的,能够考虑预加载。
做缓存的价值便是要占用些内存,这也是前面MediaData为什么复用parent和mime的原因。
缓存是空间换时刻,复用目标是时刻换空间,整体而言这个对冲是赚的,由于读取IO更耗时。
另外,假如有必要,能够供给clearCache接口,在适当的机遇清空缓存。
4.3.2 组装成果
相册的UI层所需求的是: 依据Request的查询条件过滤后的MediaData,以目录为分组,按更新时刻降序摆放的数据。
缓存的MediaData并非查询的终点,但却供给了一个好的起点。
在有缓存好的MediaData列表的前提下,可直接依据MediaData列表做过滤,排序和分组,
而不需求每次都将过滤条件拼接SQL到数据库中查询,并且比较于拼接SQL,在上层直接依据MediaData过滤要愈加灵活。
下面是EasyAlbum基于MediaData缓存的查询:
private static List<Folder> makeResult(AlbumRequest request) {
AlbumRequest.MediaFilter filter = request.filter;
ArrayList<MediaData> totalList = new ArrayList<>(mediaCache.size());
if (filter == null) {
totalList.addAll(mediaCache.values());
} else {
// 依据filter过滤MediaData
for (MediaData item : mediaCache.values()) {
if (filter.accept(item)) {
totalList.add(item);
}
}
}
// 先对一切MediaData排序,后面分组后就不需求持续在分组内排序了
// 由于分组时是按次序放到分组列表的。
Collections.sort(totalList);
Map<String, ArrayList<MediaData>> groupMap = new HashMap<>();
for (MediaData item : totalList) {
String parent = item.parent;
ArrayList<MediaData> subList = groupMap.get(parent);
if (subList == null) {
subList = new ArrayList<>();
groupMap.put(parent, subList);
}
subList.add(item);
}
final List<Folder> result = new ArrayList<>(groupMap.size() + 1);
for (Map.Entry<String, ArrayList<MediaData>> entry : groupMap.entrySet()) {
String folderName = Utils.getFileName(entry.getKey());
result.add(new Folder(folderName, entry.getValue()));
}
// 对目录排序
Collections.sort(result, request.folderComparator);
// 最后,总列表放在最前
result.add(0, new Folder(request.getAllString(), totalList));
return result;
}
MediaFilter的界说如下:
public interface MediaFilter {
boolean accept(MediaData media);
// To identify the filter
String tag();
}
基于MediaData缓存列表的查询尽管比基于数据库的查询快不少,可是当文件许多时,也仍是要花一些时刻的。
所以咱们能够再加一个缓存:缓存最终成果。
再加一个成果缓存,仅仅添加了些容器,容器指向的目标(MediaData)是之前MediaData缓存列表所引证的目标,所以价值还好。
再次进入相册时,能够先直接取成果显现,然后再去检查MediaStore相关于缓存有没有改变,有则改写缓存和UI,不然直接回来。
APP或许有多个当地需求相册,不同当地查询条件或许不相同,所以MediaFilter界说了tag接口,用来区分不同的查询。
4.3.3 加载流程
流程图如下:
留意,下图的“成果”是供给给相册页面显现的数据,并非相册回来给调用者的“已选中的媒体”。
做了两层缓存,加载流程是杂乱一些。
但好处也是清楚明了的,添加了成果缓存之后,再次发动相册就基本是“秒开”了。
查询过程是在后台线程中执行的,成果经过handler发送给AlbumActivity。
图中还有一些小处理没画出来。
比方,首次加载,在发送成果给相册界面之后,还会持续执行一个“检查文件是否已删去”的操作。
针对的是这么一种情况:MediaStore中的记载,DATA字段所对应的文件不存在。
我自己的设备上是没有出现过这种case, 我也是听长辈讲的,或许他们遇到过。
假如确实有设备存在这样的情况,确实应该检查一下,不然相册滑动到这些“文件不存在”的记载时,会只看到一片黑,稍微影响体会。
但由于我自己没有具体考证,所以在EasyAblum的全局装备中留了option, 能够设置不执行。
关于这点我们按具体情况自行评价。
加载流程一般在进入相册页时发动。
考虑到用户在浏览相册时,有时分或许会切换出去摄影或许删去相片,可在onResume的时分也发动一下加载流程,检查是否有媒体文件增删。
五、相册列表
5.1 媒体缩略图
Android体系对相册文件供给了获取缩略图的API,经过该API获取图片要比直接读取媒体文件自身要快许多。
一些图片加载结构中有完结相关逻辑,比方Glide的完结了MediaStoreImageThumbLoader和MediaStoreVideoThumbLoader,可是所用API比较旧,在我的设备(Android 10)上现已不生效了。
假如运用Glide的朋友能够自行完结ModelLoader和ResourceDecoder来处理。
EasyAlbum的Demo中有完结,感兴趣的朋友能够参阅一下。
5.2 列表布局
相册列表通常是方格布局,假如RecycleView布局,最好能让每一列都等宽。
下面这个ItemDecoration的完结是其间一种办法:
public class GridItemDecoration extends RecyclerView.ItemDecoration {
private final int n; // 列的数量
private final int space; // 列与列之间的距离
private final int part; // 每一列应该分摊多少距离
public GridItemDecoration(int n, int space) {
this.n = n;
this.space = space;
// 总距离:space * (n - 1) ,等分n份
part = space * (n - 1) / n;
}
@Override
public void getItemOffsets(
@NonNull Rect outRect,
@NonNull View view,
@NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
int position = parent.getChildLayoutPosition(view);
int i = position % n;
// 第i列(0开端)的左面部分的距离的核算公式:space * i / n
outRect.left = Math.round(part * i / (float) (n - 1));
outRect.right = part - outRect.left;
outRect.top = 0;
outRect.bottom = space;
}
}
其原理便是将一切space加起来,等分为n份,每个item分摊1份。
其间第i列(index从0开端)的左面部分的距离的核算公式为:space * i / n 。
比方说colomn = 4, 那么就有3个space; 假如每个space=4px, 则每个item分摊4 * (4-1)/ 4 = 3px。
第1个item, left=0px, right = 3px;
第2个item, left=1px, right = 2px;
第3个item, left=2px, right =1px;
第4个item, left=3px, right =0px。
于是,每个距离看起来都是4px, 且每个item的left+right都是持平的,所以留给view的宽度是持平的。
作用如下图:
有的当地是这么去分配left和right的:
outRect.left = column == 0 ? 0 : space / 2;
outRect.right = column == (n - 1) ? 0 : space / 2;
这样能让每个距离的巨细持平,可是view自身的宽度就不持平了。
作用如下图:
左右两个item分别比中心的item多了2px。
这2px看上去不多,可是或许会导致列表改变(增删)时,图片结构的缓存失效。
例如:
假如删去了最接近的一张相片,原第2-4列会移动到1-3列,原第1列会移动到第4列。
于是第2列的宽度从266变为288,第4列的宽度从288变为266,
而图片加载结构的target宽高是缓存key的核算要素之一,宽度变了,就不能射中之前的缓存了。
六、后序
相册的完结可简略可杂乱,我见过的最简略的完结是直接在主线程查询媒体数据库的……
本文从各个方面共享了一些相册完结的经验,尤其是相册加载部分。
现在这个年代,手机存几千上万张图片是很常见的,优化好相册的加载,能提升不少用户体会。
项目已发布到Github和Maven Central:
Githun地址: github.com/BillyWei01/…
下载方式:
implementation 'io.github.billywei01:easyalbum:1.0.6'