
简介这是一份面向Android初中级开发者的相册功能实现参考源码围绕图片浏览、选择与预览等常见需求整合了文件系统访问、MediaStore查询、Intent数据传递、图像加载库缓存、RecyclerView网格布局、运行时权限申请、手势缩放与图片裁剪等核心知识点适合用于课程设计、练手项目或功能模块拆解学习。资源包共130个文件以52个png图片素材、36个xml布局与配置、32个java源码为主另含少量jpg、md说明及工程配置文件压缩包约246KB体量轻便便于快速导入阅读。目前已有809人学习下载。源码中可见ImageGalleryView、ImageManager、ImageListView、ImageViewTouchBase、JoyImageViewActivity等类覆盖图片管理、列表展示、触摸缩放与自定义视图等模块读者可据此梳理相册类应用的目录结构与实现脉络并参考其权限检查、异常处理与性能优化思路快速搭建自己的图片浏览与选择界面。1. 从零拆解一个 Android 相册 Demo它到底能解决什么做 Android 应用开发的人几乎都绕不开一个需求把设备里的图片按相册维度聚合展示点进去能滑动浏览、能多选、能拿到真实路径。这个需求听起来简单真动手写才发现坑不少——MediaStore 查询出来的数据顺序是乱的不同 Android 版本权限模型完全不一样缩略图和原图加载策略稍有不慎就 OOM。我这次拆的是一个「Android 实现类似相册功能」的完整源码包它把系统相册最核心的三件事做完了按目录聚合图片、网格缩略图展示、点击进入大图浏览。适合两类人一是刚接触 Android 多媒体开发、想找一个能跑通的参考实现的新手二是做过业务开发但没系统写过相册模块、想看看标准做法的熟手。它不依赖任何第三方图片库也能跑但如果你想接 Glide 或 Coil接口留得很干净。2. 相册功能的技术底座MediaStore 查询与权限适配2.1 为什么是 MediaStore 而不是直接遍历文件很多人第一反应是拿File.listFiles()去扫 DCIM 和 Pictures 目录。这个做法在 Android 10 之前勉强能用之后基本走不通。原因有两层一是分区存储Scoped Storage强制推行后应用没有权限直接读取其他应用创建的图片文件二是即便你申请到了READ_EXTERNAL_STORAGE遍历整个存储卡也是极慢的操作用户手机里几千张图扫一遍要好几秒。MediaStore 是 Android 系统提供的媒体数据库系统在后台已经把图片、视频、音频的元数据索引好了。你查 MediaStore 相当于查一张已经建好索引的表速度比遍历文件快一个数量级。这个 Demo 里用的是MediaStore.Images.Media.EXTERNAL_CONTENT_URI配合ContentResolver.query()拿游标这是标准做法。关键字段就那几个_ID图片唯一标识、BUCKET_ID和BUCKET_DISPLAY_NAME所在目录的 ID 和名称用来做相册聚合、DATA文件绝对路径Android 10 之后不推荐直接用、DATE_ADDED添加时间用来排序、WIDTH和HEIGHT尺寸用来算缩略图采样率。2.2 权限申请的版本差异这是最容易翻车的地方。Android 6.0 到 9.0 用READ_EXTERNAL_STORAGE一个权限就够了。Android 10 引入了分区存储但可以通过requestLegacyExternalStorage临时退回旧模式。Android 11 之后又变了读图片要用READ_MEDIA_IMAGESAndroid 13 起Android 11 和 12 上则是READ_EXTERNAL_STORAGE仍然有效但行为有差异。Demo 里的做法是按版本分支申请核心逻辑如下// 根据系统版本选择不同的读取权限 private String[] getRequiredPermissions() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13 使用细分媒体权限 return new String[]{Manifest.permission.READ_MEDIA_IMAGES}; } else { // Android 12 及以下沿用存储权限 return new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}; } } // 检查并请求权限 private void checkPermission() { String[] perms getRequiredPermissions(); boolean allGranted true; for (String p : perms) { if (ContextCompat.checkSelfPermission(this, p) ! PackageManager.PERMISSION_GRANTED) { allGranted false; break; } } if (!allGranted) { ActivityCompat.requestPermissions(this, perms, REQ_CODE); } else { loadAlbums(); // 权限已具备开始查询 } }逻辑说明getRequiredPermissions()把版本判断收在一处避免散落各处。checkPermission()先遍历检查全部授予才走加载流程否则发起请求。参数上REQ_CODE是自定义的请求码在onRequestPermissionsResult里要对应处理。提示Android 14 开始如果应用只是偶尔读图可以考虑用 Photo Picker不需要申请任何权限。但相册类应用通常需要全量访问还是得走权限申请。2.3 查询与相册聚合的完整实现拿到权限后查询本身不复杂难的是把扁平的图片列表聚合成「相册」结构。Demo 里的思路是先按BUCKET_ID分组每组取第一张图作为封面组内按时间倒序排列。// 查询所有图片并按相册目录聚合 private void loadAlbums() { // 按添加时间倒序保证最新的图在前面 String sortOrder MediaStore.Images.Media.DATE_ADDED DESC; // 只取需要的列减少内存占用 String[] projection { MediaStore.Images.Media._ID, MediaStore.Images.Media.BUCKET_ID, MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT }; Cursor cursor getContentResolver().query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder); // 用 LinkedHashMap 保持插入顺序key 是 bucketId MapString, Album albumMap new LinkedHashMap(); if (cursor ! null) { int idCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID); int bucketIdCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_ID); int bucketNameCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME); while (cursor.moveToNext()) { long id cursor.getLong(idCol); String bucketId cursor.getString(bucketIdCol); String bucketName cursor.getString(bucketNameCol); Album album albumMap.get(bucketId); if (album null) { album new Album(bucketId, bucketName); albumMap.put(bucketId, album); } album.addImage(id); // 组内追加图片 ID } cursor.close(); } // 转成 List 交给 RecyclerView 展示 ListAlbum albums new ArrayList(albumMap.values()); adapter.submitList(albums); }逻辑说明projection只取必要列避免把DATA这种长字符串也读进来。LinkedHashMap保证相册顺序和查询顺序一致第一个出现的相册就是最近有更新的。Album.addImage()内部维护一个图片 ID 列表封面取第一张。参数上sortOrder用DATE_ADDED DESC是最常见的但注意DATE_ADDED是秒级时间戳同一秒内多张图顺序可能不稳定严格场景可以再加_ID DESC做次级排序。3. 缩略图加载与列表性能别让 OOM 找上门3.1 为什么不能直接加载原图一张 4000×3000 的 JPEG按 ARGB_8888 解码到内存里是 4000×3000×4 字节约 48MB。RecyclerView 一屏显示 9 张如果都按原图加载瞬间就是 400 多 MB必崩无疑。所以缩略图必须做采样压缩核心是BitmapFactory.Options的inSampleSize参数。Demo 里的做法是先用inJustDecodeBounds true读图片尺寸算出合适的采样率再真正解码。// 按目标尺寸计算采样率并解码缩略图 public static Bitmap decodeThumbnail(ContentResolver resolver, long imageId, int targetSize) { Uri uri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageId); // 第一步只读边界不分配像素内存 BitmapFactory.Options opts new BitmapFactory.Options(); opts.inJustDecodeBounds true; try (InputStream is resolver.openInputStream(uri)) { BitmapFactory.decodeStream(is, null, opts); } catch (IOException e) { return null; } // 第二步计算 inSampleSize取 2 的幂次 opts.inSampleSize calculateInSampleSize( opts.outWidth, opts.outHeight, targetSize); opts.inJustDecodeBounds false; // 使用 RGB_565 进一步减半内存占用 opts.inPreferredConfig Bitmap.Config.RGB_565; try (InputStream is resolver.openInputStream(uri)) { return BitmapFactory.decodeStream(is, null, opts); } catch (IOException e) { return null; } } // 计算最接近目标尺寸的 2 的幂次采样率 private static int calculateInSampleSize(int w, int h, int target) { int sample 1; while (w / sample target * 2 h / sample target * 2) { sample * 2; } return sample; }逻辑说明inJustDecodeBounds阶段只读文件头不分配像素内存开销极小。calculateInSampleSize用 2 的幂次是因为inSampleSize非 2 的幂次时系统会向下取整到最近的 2 的幂次不如自己算准。RGB_565比ARGB_8888每像素省一半内存缩略图场景肉眼几乎看不出差别。参数上targetSize一般取 ImageView 的实际像素宽度比如网格是 3 列屏幕宽 1080px那每格约 360pxtargetSize传 360 即可。3.2 用 LruCache 做内存缓存采样解决了单张图的内存问题但列表滑动时反复解码仍然卡顿。Demo 里加了一层LruCachekey 用图片 IDvalue 是解码后的 Bitmap。// 内存缓存大小为可用内存的 1/8 private final LruCacheLong, Bitmap memoryCache; public ThumbnailLoader() { int maxMemory (int) (Runtime.getRuntime().maxMemory() / 1024); int cacheSize maxMemory / 8; memoryCache new LruCacheLong, Bitmap(cacheSize) { Override protected int sizeOf(Long key, Bitmap value) { // 按 KB 计算每张图占用 return value.getByteCount() / 1024; } }; }逻辑说明maxMemory / 8是 Android 官方推荐的缓存上限既不会太激进导致 OOM也不会太小频繁回收。sizeOf必须重写否则默认按条目数算一张大图和一张小图权重一样不合理。注意LruCache不是线程安全的如果解码放在子线程存取缓存也要加锁或者用synchronized包一层。Demo 里用的是单线程 Executor规避了这个问题但并发场景要留意。3.3 滑动时暂停加载的正确姿势RecyclerView 快速滑动时如果还在不停解码主线程会被拖累。标准做法是监听onScrollStateChanged在SCROLL_STATE_FLING时暂停加载任务停下来再恢复。// 滑动时暂停图片加载停止后恢复 recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrollStateChanged(RecyclerView rv, int newState) { if (newState RecyclerView.SCROLL_STATE_IDLE) { loader.resume(); // 停止滑动恢复加载 } else { loader.pause(); // 正在滑动或惯性滑动暂停 } } });逻辑说明pause()只是设置一个标志位让正在排队的任务不执行解码而不是取消已提交的任务。resume()后重新触发当前可见项的加载。这个机制配合LruCache滑动时命中缓存的图直接显示未命中的等停下来再解码体验最顺。4. 大图浏览与手势从缩略图到全屏的衔接4.1 ViewPager2 承载大图列表点击缩略图进入大图浏览Demo 用的是ViewPager2加FragmentStateAdapter。每个页面是一个PhotoFragment内部用ImageView配合自定义的Matrix做缩放和拖拽。为什么不用ViewPager一代ViewPager2基于RecyclerView支持竖向滑动、支持DiffUtil、懒加载更彻底而且FragmentStateAdapter在页面销毁时能正确回收 Fragment内存表现更好。// 大图浏览的适配器按相册内图片列表构建 public class PhotoPagerAdapter extends FragmentStateAdapter { private final ListLong imageIds; public PhotoPagerAdapter(FragmentActivity activity, ListLong ids) { super(activity); this.imageIds ids; } Override public int getItemCount() { return imageIds.size(); } NonNull Override public Fragment createFragment(int position) { // 每个位置创建一个 Fragment传入图片 ID return PhotoFragment.newInstance(imageIds.get(position)); } }逻辑说明imageIds是当前相册的图片 ID 列表从缩略图页面传过来。createFragment按位置创建 FragmentFragmentStateAdapter会自动处理 Fragment 的创建和销毁。参数上注意imageIds要用不可变列表避免外部修改导致getItemCount和实际不一致。4.2 大图解码采样率要跟着屏幕走大图浏览时图片显示区域是整屏采样率不能再用缩略图那套。Demo 里的做法是取屏幕宽高的较大值作为目标尺寸保证图片清晰度够用又不浪费内存。// 大图解码目标尺寸取屏幕对角线像素 private Bitmap decodeFullImage(long imageId) { DisplayMetrics dm getResources().getDisplayMetrics(); int target Math.max(dm.widthPixels, dm.heightPixels); // 复用缩略图的采样逻辑但目标尺寸更大 return ThumbnailLoader.decodeThumbnail( getContentResolver(), imageId, target); }逻辑说明Math.max(widthPixels, heightPixels)保证横屏和竖屏都能覆盖。如果图片本身比屏幕小calculateInSampleSize会返回 1即不采样保持原分辨率。这里复用了缩略图的解码方法只是targetSize不同代码复用度高。4.3 手势缩放与边界处理自定义ImageView处理ScaleGestureDetector和GestureDetector实现双指缩放和单指拖拽。核心是维护一个Matrix缩放时以手指中心为焦点拖拽时限制边界不让图片滑出屏幕。// 缩放时以焦点为中心并限制最小最大倍数 Override public boolean onScale(ScaleGestureDetector detector) { float scaleFactor detector.getScaleFactor(); float newScale currentScale * scaleFactor; // 限制缩放范围在 1x 到 3x 之间 if (newScale 1.0f) { scaleFactor 1.0f / currentScale; } else if (newScale 3.0f) { scaleFactor 3.0f / currentScale; } // 以手指焦点为中心缩放 matrix.postScale(scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY()); currentScale * scaleFactor; setImageMatrix(matrix); return true; }逻辑说明detector.getFocusX/Y()是双指中心点以它为焦点缩放视觉上最自然。currentScale记录当前倍数用于边界判断。参数上最小 1x 最大 3x 是常见选择具体可以按产品需求调。提示拖拽边界计算要考虑图片缩放后的实际宽高和 View 宽高的差值Demo 里在onTouch的ACTION_MOVE里做了 clamp逻辑不复杂但容易漏建议单独写个clampTranslation方法。5. 避坑与排查那些让我重装三次应用的坑5.1 查询返回空游标但手机里明明有图现象权限申请通过了query()返回的Cursor不为 null但getCount()是 0。原因Android 10 及以上如果应用没有设置requestLegacyExternalStorage且查询的是EXTERNAL_CONTENT_URI系统只会返回本应用创建的图片。其他应用比如系统相机拍的图不在结果里。解决在AndroidManifest.xml的application标签加android:requestLegacyExternalStoragetrue仅对 Android 10 有效。Android 11 及以上必须用READ_EXTERNAL_STORAGE或READ_MEDIA_IMAGES并且确保权限真的授予了。还有一种情况是查询列名写错getColumnIndexOrThrow会直接抛异常不会静默返回 0所以能排除。5.2 缩略图错位滑动后图片对不上现象RecyclerView 快速滑动停下来后某些位置的图片和实际内容不匹配。原因ViewHolder 复用导致。异步加载图片时如果没做 tag 校验先发起的请求后返回就会覆盖掉后发起请求的结果。解决在onBindViewHolder里给 ImageView 设置一个 tag值为图片 ID。异步加载完成后先检查imageView.getTag()是否等于当前请求的 ID不等就丢弃结果。// 绑定 ViewHolder 时设置 tag防止图片错位 Override public void onBindViewHolder(NonNull VH holder, int position) { long imageId getItem(position); holder.imageView.setTag(imageId); // 标记当前请求的图片 ID loader.load(imageId, bitmap - { // 回调时校验 tag不匹配说明 ViewHolder 已被复用 if (holder.imageView.getTag().equals(imageId)) { holder.imageView.setImageBitmap(bitmap); } }); }5.3 大图滑动到一半白屏现象ViewPager2 滑动大图时中间几张显示空白等一会儿才出来。原因FragmentStateAdapter默认只保留当前页和相邻页滑动速度快时新页面的 Fragment 还没创建完ImageView 是空的。解决给 ImageView 设置一个占位背景色或者用setOffscreenPageLimit预加载相邻页。Demo 里用的是占位色加淡入动画体验上比白屏好很多。另外大图解码放在子线程解码完成后用post回主线程设置避免阻塞滑动。5.4 Android 13 上权限申请弹窗不出现现象在 Android 13 设备上调用requestPermissions后没有任何弹窗直接回调 denied。原因Android 13 把媒体权限拆成了READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO如果还在申请READ_EXTERNAL_STORAGE系统会直接拒绝不弹窗。解决按 2.2 节的版本分支逻辑Android 13 及以上申请READ_MEDIA_IMAGES。同时检查targetSdkVersion如果 target 到了 33必须用新权限否则应用可能被系统限制。5.5 查询速度慢首屏加载要两三秒现象图片多的设备上首次进入相册要等很久。原因projection里带了DATA列或者sortOrder用了没有索引的列。解决只取必要列DATA列在 Android 10 之后本来就不推荐用。sortOrder用DATE_ADDED DESC是有索引的但如果加了BUCKET_DISPLAY_NAME排序就会慢。另外查询可以放在子线程先展示空列表加 loading查完再刷新。Demo 里用的是ExecutorService单线程池避免并发查询。6. 进阶技巧把相册模块做成可复用的组件上面几章把功能跑通了但如果要在多个项目里复用直接拷代码不是好办法。我一般会把相册模块抽成三个层次数据层、加载层、UI 层。数据层只暴露queryAlbums()和queryImages(bucketId)两个方法返回纯数据对象加载层封装缩略图和大图的解码、缓存、线程调度UI 层就是 Activity 和 Adapter只负责展示和交互。这样拆的好处是换 UI 框架比如从 View 体系换到 Compose时数据层和加载层不用动。下面是一个数据层的接口定义// 相册数据层接口隔离 MediaStore 细节 public interface AlbumRepository { // 查询所有相册回调在主线程 void queryAlbums(CallbackListAlbum callback); // 查询指定相册下的所有图片 ID void queryImages(String bucketId, CallbackListLong callback); interface CallbackT { void onResult(T data); void onError(Exception e); } }逻辑说明Callback把结果抛回调用方内部实现可以用线程池加 Handler 切主线程。queryImages返回的是 ID 列表而不是 Bitmap加载层再按需解码职责清晰。验证方法上我习惯用 Android Studio 的 Layout Inspector 看 RecyclerView 的 ViewHolder 复用情况用 Profiler 看内存曲线。如果滑动时内存曲线是锯齿状但整体平稳说明缓存生效如果持续上升不回落大概率是 Bitmap 没回收或者缓存上限设太大。还有一个具体技巧MediaStore查询时可以用Bundle传ContentResolver.QUERY_ARG_SQL_SORT_ORDER这是 Android 11 引入的新 API比拼字符串更安全也支持QUERY_ARG_LIMIT做分页。分页在图片上万张的设备上很有用首屏只查 500 张滑到底再查下一批。// Android 11 用 Bundle 传查询参数支持分页 Bundle queryArgs new Bundle(); queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, MediaStore.Images.Media.DATE_ADDED DESC); queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, 500); queryArgs.putInt(ContentResolver.QUERY_ARG_OFFSET, offset); Cursor cursor getContentResolver().query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, queryArgs, null);逻辑说明QUERY_ARG_LIMIT和QUERY_ARG_OFFSET配合实现分页offset每次累加 500。注意这个 API 只在 Android 11 及以上有效低版本还是要用sortOrder字符串加手动截断。从那以后我每次写相册类需求都强制走一遍「权限分支检查 → projection 精简 → 采样率计算 → tag 校验 → 滑动暂停」这五步少一步都可能在上线后收到用户反馈。希望帮到你。本文还有配套的精品资源点击获取