Android相册开发实战!(附源码)
安卓进阶涨薪训练营,让一部分人先进大厂
大家好,我是皇叔,最近开了安卓进阶涨薪训练营第二期,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。
详情见文章:皇叔安卓训练营第二期开启啦!
作者:呼啸长风
https://juejin.cn/user/2999123450531982
序
我之前发布了个图片加载框架,在JCenter关闭后,“闭关修炼”,想着改好了出个2.0版本。后来觉得仅增加功能和改进实现不够,得补充一下用例。相册列表的加载就是很好的用例,然后在Github找了一圈,没有找到满意的,有的甚至好几年没维护了,于是就自己写了一个。
代码链接:
https://github.com/BillyWei01/EasyAlbum
相比于图片加载,相册加载在Github上要多很多。其原因大概是图片加载的input/output比较规范,不涉及UI布局;而相册则不然,几乎每个APP都会有自己独特的需求,有自己的UI风格。因此,相册库很难做到通用于大部分APP。我所实现的这个也一样,并非以实现通用的相册组件为目的,而是作为一个样例,以供参考。
需求描述
网上不少相册的开源库,都是照微信相册来搭的界面,我也是跟着这么做吧,要是说涉及侵权什么的,那些前辈应该先比我收到通知……
主要是自己也不会UI设计,不找个参照对象怕实现的太难看。话说回来,要是真的涉及侵权,请联系我处理。相册所要实现的功能,概括来说,就是显示相册列表,点击缩略图选中,点击完成结束选择,返回选择结果。需求细节,包括但不限于以下列表:
-
实现目录列表,相册列表,预览页面; -
支持单选/多选; -
支持显示选择顺序和限定选择数量; -
支持自定义筛选条件; -
支持自定义目录排序; -
支持“原图”选项; -
支持再次进入相册时传入已经选中的图片/视频; -
支持切换出APP外拍照或删除照片后,回到相册时自动刷新;
API设计
EasyAlbum.config()
.setImageLoader(GlideImageLoader)
.setDefaultFolderComparator { o1, o2 -> o1.name.compareTo(o2.name)}
public interface ImageLoader {
void loadPreview(MediaData data, ImageView imageView, boolean asBitmap);
void loadThumbnail(MediaData data, ImageView imageView, boolean asBitmap);
}
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)
}
EasyAlbum.from(this)
.setFilter(TestMediaFilter(option))
.setSelectedLimit(selectLimit)
.setOverLimitCallback(overLimitCallback)
.setSelectedList(mediaAdapter?.getData())
.setAllString(option.text)
.enableOriginal()
.start { result ->
mediaAdapter?.setData(result.selectedList)
}
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;
}
}
}
-
通过intent传参数到AlbumActivity, 用startActivityForResult启动,通过onActivityResult接收。 -
通过静态变量传递参数,通过Callback回调结果。
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;
}
}
}
媒体文件加载
public final Cursor query(
Uri uri,
String[] projection,
String selection,
String[] selectionArgs,
String sortOrder,
CancellationSignal cancellationSignal) {
}
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
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
+ ")";
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
};
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;
}
// ......
}
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));
// ......
}
public int getWidth() {
return rotate != ROTATE_YES ? width : height;
}
public int getHeight() {
return rotate != ROTATE_YES ? height : width;
}
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) {
}
}
}
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;
}
public interface MediaFilter {
boolean accept(MediaData media);
// To identify the filter
String tag();
}
相册列表
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;
}
}
-
第1个item的left=0px, right = 3px; -
第2个item的left=1px, right = 2px; -
第3个item的left=2px, right =1px; -
第4个item的left=3px, right =0px。
outRect.left = column == 0 ? 0 : space / 2;
outRect.right = column == (n - 1) ? 0 : space / 2;
后序
相册的实现可简单可复杂,我见过的最简单的实现是直接在主线程查询媒体数据库的……本文从各个方面分享了一些相册实现的经验,尤其是相册加载部分。目前这个时代,手机存几千上万张图片是很常见的,优化好相册的加载,能提升不少用户体验。项目已发布到Github和Maven Central。
https://github.com/BillyWei01/EasyAlbum
implementation 'io.github.billywei01:easyalbum:1.0.6'
为了防止失联,欢迎关注我防备的小号
微信改了推送机制,真爱请星标本公号👇