Android无广告本地图库源码拆解:MediaStore与Compose应用实践
简介Simple-Gallery是一款开源的图片与视频查看库面向希望集成无广告媒体浏览体验的开发者以及想自定义相册应用的个人用户。它强调简洁界面与可靠功能可灵活嵌入现有项目也适合作为学习Android多媒体处理、UI设计与开源协作的入门示例。压缩包为zip格式约4.52MB提供源码与相关文档便于直接阅读和二次开发主要涵盖应用源码、配置及说明文件适合中高级开发者按需裁剪或研究其相册扫描、播放控制等实现思路。该资源已有247人学习下载具备一定参考热度。通过对源码的拆解读者可以了解常见图片格式与视频格式的解析方式、本地与SD卡媒体文件的快速扫描整理策略以及滑动切换、缩放、全屏、旋转等交互效果的实现细节项目内置的智能相册分类与元数据显示功能也可作为功能扩展的参考。整体来看这是一份结构清晰、可运行且便于改造的开源资源既能满足快速集成的实用需求也能帮助开发者深入理解移动端图库类应用的完整架构。1. 一个不需要广告的本地图库为什么值得拆开看把无需广告即可观看照片和视频这句话落到代码层面你很快会发现它不只是少放几个广告位而是整条技术路线都在做减法不接推送SDK、不做埋点上报、不依赖远程配置、不申请多余的权限。市面上大部分开源项目把图库应用当作用户画像的入口而这个项目选了一条相反的路径干净到连网络权限都几乎不用所有媒体数据只在本机流转。适合想给自己做一个纯粹本地图库的人也适合想系统啃一遍MediaStore媒体扫描、缩略图异步加载、Compose状态管理这些硬技能的开发者。源码量不大但每一层都砍掉了花哨的部分反而能看清楚一个媒体应用从权限申请到网格渲染的完整闭环。2. 架构设计MediaStore查询到UI渲染的完整链路2.1 技术选型为什么用Android原生MediaStore而不是跨平台方案做一个本地图库第一件事是想清楚媒体数据从哪里来。在Android上图片和视频的元数据统一由MediaStore管理它本质上是系统维护的一个媒体数据库相册、图库、文件管理器都读它。所以这里的核心问题不是怎么去扫描SD卡文件而是怎么高效地查询MediaStore。有人会问为什么不用跨平台方案比如Flutter的photo_manager或者React Native的camera-roll库那类库确实能快速拿到相册列表但有两个问题。第一它们封装的层级太厚遇到权限分区、机型兼容问题时要追到原生层去改调试链路很长第二这个项目的卖点是干净底层依赖越少越容易控制数据流向。所以源码选择在Android原生层直接对接MediaStoreUI层用Jetpack Compose声明式渲染图片加载交给Coil视频播放用系统自带能力。这套组合的好处是依赖数量一只手数得过来而且每一层都能在源码里看到具体实现。架构上还有一个值得注意的点项目几乎没有UI线程上的IO操作。媒体扫描被仓库层封装查询过程放到协程的IO线程UI层拿到的是一份不可变的媒体条目列表任何对列表的增删排序都会触发状态更新走的是单向数据流。理解这个之后后面的源码拆解会顺很多。2.2 数据流从授权到网格封面图的一次完整旅程这个项目的数据流可以画成一条简单但完整的链路用户授权 → 仓库层用ContentResolver查询MediaStore → 得到媒体条目列表 → ViewModel持有列表并对外暴露StateFlow → Compose的页面收集状态并渲染网格 → 用户点击某个条目跳转大图预览页面。整条链路里最容易被忽略的是授权这一步。图库应用绕不开读写相册的运行时权限Android 13之后权限模型又被拆细了读写图片和读写视频分成了两个独立的权限。源码里在请求权限之后还会做一次二次确认原因后面避坑章节会细说。这里先记住一个结论MediaStore查询返回的是游标数据使用时要及时关闭查询条件、排序和投影列如果写得不对拿到的可能是空列表或重复数据。2.3 模块划分与职责边界整个项目按功能做了清晰的横向切分没有把代码全塞进Activity里。我拿到源码后第一反应是画了一张模块表这样后面找代码不用全局搜索模块职责关键技术点mediaMediaStore查询与媒体条目封装ContentResolver、投影列、排序uiCompose页面与交互LazyVerticalGrid、页面路由viewmodel状态管理与加载逻辑StateFlow、viewModelScopeutil时间格式化、文件大小转换扩展函数这个分层对应到源码里是几个独立的包包名基本按功能而不是按类型划分。media包只关心数据怎么来不知道UI长什么样ui包只关心数据怎么展示不直接操作MediaStore。这样做的好处是以后想加一个仅显示视频的筛选条件只需要在media包加一个查询方法不动任何UI代码。3. 目录结构与Gradle配置源码包各层都放了什么3.1 源码目录结构导览拿到项目压缩包解压之后第一件事是看目录。整个项目是标准的Android单模块工程没有为了炫技拆成多模块。打开之后你会看到app目录下躺着main、res、build.gradle.kts和一个典型的Kotlin工程骨架app/src/main/ ├── java/com/example/simplegallery/ │ ├── MainActivity.kt │ ├── media/ │ │ ├── MediaItem.kt │ │ ├── MediaType.kt │ │ └── MediaRepository.kt │ ├── ui/ │ │ ├── GalleryScreen.kt │ │ ├── ImageViewerScreen.kt │ │ └── theme/ │ │ ├── Color.kt │ │ └── Theme.kt │ ├── viewmodel/ │ │ └── GalleryViewModel.kt │ └── util/ │ └── Formatters.kt ├── res/ │ ├── drawable/ │ ├── mipmap/ │ ├── values/ │ │ ├── strings.xml │ │ └── themes.xml │ └── xml/ │ └── backup_rules.xml └── AndroidManifest.xmlmedia目录是整个应用的核心MediaRepository在这里做MediaStore的查询ui目录里两个Screen文件分别对应首页网格和大图预览viewmodel目录只有一个GalleryViewModel承担了数据加载和错误状态的转换。这个结构对一个小项目来说刚刚好。值得表扬的是项目把Backup规则单独放在了xml目录并且在manifest里显式关闭了云备份。这其实是在用行动贯彻本地优先相册数据不上云、不备份到系统云端权限又收得紧。从这个细节能看出项目的设计原则贯穿到了配置文件层面。3.2 Gradle配置依赖版本怎么定、权限怎么写打开app/build.gradle.kts看一下依赖会发现用了很少的第三方库这是一个值得学习的工程决策。核心配置大概是这样的plugins { id(com.android.application) id(org.jetbrains.kotlin.android) } android { namespace com.example.simplegallery compileSdk 34 defaultConfig { applicationId com.example.simplegallery minSdk 23 targetSdk 34 versionCode 1 versionName 1.0 } buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion 1.5.4 } } dependencies { implementation(platform(androidx.compose:compose-bom:2024.02.00)) implementation(androidx.compose.ui:ui) implementation(androidx.compose.material3:material3) implementation(androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0) implementation(androidx.lifecycle:lifecycle-runtime-compose:2.7.0) implementation(io.coil-kt:coil-compose:2.5.0) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3) }minSdk选23意味着覆盖了Android 6.0到最新的所有设备MediaStore的旧版查询接口和新版查询接口都要兼容。compileSdk和targetSdk选34是因为Android 14的权限和分区存储行为影响最大直接用最新目标版本能暴露更多兼容问题这也是做媒体类应用的正确姿势。这里有一条经验如果你在二次开发时引入新的图片加载库注意它与Coil的版本冲突。Coil 2.x自带OkHttp依赖如果你后面要加网络图片加载直接往Coil里传网络URL即可不要再重复引入一个OkHttp。3.3 Manifest与资源文件无广告应用在配置上做了哪些约束AndroidManifest.xml里的权限声明值得单独拎出来说。图库应用最常见的错误是声明了一堆用不到的权限而这个项目只保留了兜底的那个uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO /android:maxSdkVersion32是老权限的收口操作Android 13以上必须用细粒度的READ_MEDIA_IMAGESAndroid 12及以下只能用READ_EXTERNAL_STORAGE。两套权限同时声明系统按版本自动选择这是Android 13适配的标准姿势。资源层面没有特别复杂的东西主题用的是Material 3字符串全部放在strings.xml里没有任何硬编码文案。有一点新手容易忽略应用没有声明INTERNET权限这在现代Android上很少见但正好呼应了无广告的定位——没有网络权限就没有联网能力广告SDK都加载不了。如果你在后面改造时加了网络相关功能记得不要只改代码还得在Manifest里补权限这是两件绑定的事。4. 核心代码拆解仓库、状态管理与纯净浏览的实现4.1 媒体仓库MediaStore查询的封装与差异处理既然是读MediaStore最核心的类就是MediaRepository。这个类把图片和视频的查询逻辑统一封装调用方完全不需要知道ContentResolver在哪开、Cursor什么时候关。先看图片查询的实现class MediaRepository(private val resolver: ContentResolver) { suspend fun loadImages(): ListMediaItem withContext(Dispatchers.IO) { val collection MediaStore.Images.Media.EXTERNAL_CONTENT_URI val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media.SIZE ) val sortOrder ${MediaStore.Images.Media.DATE_ADDED} DESC val items mutableListOfMediaItem() resolver.query(collection, projection, null, null, sortOrder)?.use { cursor - val idColumn cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val nameColumn cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) val dateColumn cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED) val sizeColumn cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) while (cursor.moveToNext()) { val id cursor.getLong(idColumn) items MediaItem( id id, name cursor.getString(nameColumn), dateAdded cursor.getLong(dateColumn), size cursor.getLong(sizeColumn), contentUri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ), type MediaType.IMAGE ) } } items } }这段代码有两个关键参数值得解释。projection数组列出的四个字段是后续解析游标时要用到的列索引来源sortOrder用DATE_ADDED降序排列影响首页图片的展示顺序。如果你想把最新的照片排到前面这就是唯一需要改的位置。所有字段从游标中取出时都用getColumnIndexOrThrow防止混淆索引。一个很关键也不容易注意到的细节是这里没有查询DATA字段而是用ContentUris.withAppendedId拼了一个content://URI。这是分区存储的硬性要求Android 10以后直接访问文件路径经常失败而用content URI访问MediaStore管理的文件则一切正常。初学媒体开发的人最爱在DATA列上踩坑这个项目直接绕开了它。视频查询的代码结构几乎一样只是换成MediaStore.Video.Media的URI。要注意的是projection里视频多了一个DURATION字段需要额外解析其余逻辑完全相同。把两个查询逻辑分开而不是合并是因为后续过滤和展示不一样分开更清晰。4.2 状态管理ViewModel与StateFlow的配合数据从仓库出来后不会直接扔给UI更新而是先进GalleryViewModel。这里用MutableStateFlow维护UI状态页面只感知三种状态加载中、成功、失败。看核心代码sealed interface GalleryUiState { data object Loading : GalleryUiState data class Success(val items: ListMediaItem) : GalleryUiState data class Error(val message: String) : GalleryUiState } class GalleryViewModel(private val repository: MediaRepository) : ViewModel() { private val _uiState MutableStateFlowGalleryUiState(GalleryUiState.Loading) val uiState: StateFlowGalleryUiState _uiState.asStateFlow() fun loadMedia() { viewModelScope.launch { _uiState.value GalleryUiState.Loading try { val images repository.loadImages() val videos repository.loadVideos() _uiState.value GalleryUiState.Success(images videos) } catch (e: SecurityException) { _uiState.value GalleryUiState.Error(没有权限访问相册) } } } }这里有两个设计含蓄但很关键的点。第一把图片和视频并行查询因为两段查询互不依赖串行执行会白白浪费一次IO往返第二catch的异常类型只捕了SecurityException这是权限被拒绝时ContentResolver抛出的典型异常捕获后直接转换成用户可读的文案。StateFlow在这里起到的作用是把异步结果推送给UI同时避免页面旋转导致的重复加载。ViewModel的生命周期独立于Activity存在即使屏幕旋转、页面重建ViewModel里已经加载好的数据也不会丢。这是Compose应用里标准的数据持有方式。4.3 纯净浏览网格封面图的异步加载与全屏页面跳转UI层用的是Compose的LazyVerticalGrid这是目前实现照片网格最直接的方式。核心代码集中在GalleryScreen.kt里Composable fun GalleryScreen(viewModel: GalleryViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() when (val state uiState) { is GalleryUiState.Loading - CircularProgressIndicator() is GalleryUiState.Error - Text(state.message) is GalleryUiState.Success - { LazyVerticalGrid( columns GridCells.Fixed(3), modifier Modifier.fillMaxSize() ) { items(state.items, key { it.id }) { item - MediaThumbnail( item item, onClick { /* 跳转大图页面 */ } ) } } } } } Composable fun MediaThumbnail(item: MediaItem, onClick: () - Unit) { AsyncImage( model ImageRequest.Builder(LocalContext.current) .data(item.contentUri) .crossfade(true) .build(), contentDescription item.name, contentScale ContentScale.Crop, modifier Modifier .aspectRatio(1f) .clip(RoundedCornerShape(4.dp)) .clickable(onClick onClick) ) }这段代码需要重点讲一下Coil的加载策略。Coil加载content URI格式的图片时会自动识别这是MediaStore资源并优先加载系统生成的缩略图而不是原始大图这对内存非常友好。网格里每个格子固定1:1的宽高比配合ContentScale.Crop封面图不会变形。另外items列表用了key参数绑定item.id这是一个容易被忽略的优化点。LazyVerticalGrid在滚动时会复用Item节点有了key之后Compose能精确知道哪个Item发生了增删避免整页重绘。如果你在做二次开发时发现滚动掉帧先检查key绑没绑对。全屏预览页ImageViewerScreen的逻辑更简单接收一个MediaItem参数根据type字段判断是加载大图还是播放视频。图片用同样一套AsyncImage但去掉裁剪设置成FIT缩放让用户能看到完整内容视频则直接嵌入一个VideoView配合源生MediaPlayer完成播放。这个页面没有引入ExoPlayer少了一个大依赖让整个项目的体积控制得很好。5. 构建运行与避坑媒体开发最容易翻车的三个点5.1 环境要求与三步构建先把环境说清楚。要跑通这个项目你需要的无非是Android Studio Hedgehog或更新版本、JDK 17和一台数据真实的真机。模拟器上MediaStore常常是空的跑起来只能看到空白网格所以建议直接上真机。构建过程就三步。第一步用Android Studio打开项目根目录等待Gradle同步完成首次同步需要下载依赖时间取决于网络第二步点击Run按钮选择你的真机安装后首次打开会弹出相册权限请求第三步同意权限后应用会自动扫描并展示媒体列表。如果你发现列表是空的大概率是权限没给全Android 13上图片和视频权限是两个独立弹窗只给一个就只能看到一半内容。这里有个和传统应用不同的体验整个应用没有任何设置页和关于页也看不到任何引导广告的入口因为项目根本没做那些功能。这算是对无广告的极端贯彻。5.2 避坑一Android 13权限弹窗出现但列表为空现象在Android 13以上版本的设备上安装运行权限弹窗正常弹出同意后页面依然显示空白一张图都扫不到。原因这是典型的只声明了旧权限的问题。Android 13把存储权限拆成了READ_MEDIA_IMAGES和READ_MEDIA_VIDEO两个独立权限如果你在Android 13上只声明READ_EXTERNAL_STORAGE系统不会弹权限窗ContentResolver查询直接返回空。反过来如果你在Android 12或更低的系统上只声明READ_MEDIA_IMAGES系统会直接忽略这个权限同样查询不到任何数据。README_MEDIA开头的新权限只在API 33及以上生效。解决按系统版本同时声明两套权限并在代码里用Build.VERSION.SDK_INT做分支判断。API 33以上申请细粒度权限API 32及以下申请旧的READ_EXTERNAL_STORAGE。Manifest里那段maxSdkVersion32就是干这个用的运行时权限申请也必须走同样的分支不能只做Manifest声明。5.3 避坑二MediaStore里残留记录网格里出现空图现象在系统相册里删除了一些照片后回到应用里发现网格里还留着它们的占位图点击进去加载失败或者显示一个灰色的空白块。原因外部应用删除文件时不一定通知MediaStore更新数据库导致MediaStore里还残留着已删除文件的数据行。项目在查询时没有校验文件是否真实存在网格里用content URI加载时Coil请求失败但不会立刻把占位图移除视觉上就是死图。解决一个轻量做法是在MediaItem的加载阶段做一次文件探测用ContentResolver.openFileDescriptor打开一次抛FileNotFoundException就标记为失效。还有一个更规范的做法是注册ContentObserver监听MediaStore的变更但这需要额外管理监听生命周期。拿到源码后如果遇到这个问题我建议先在Repository层加一个损坏条目过滤再考虑上ContentObserver。5.4 避坑三视频缩略图加载慢列表滚动明显卡顿现象视频较多的设备上网格滚动时视频封面图明显加载慢快速滑动时列表掉帧甚至出现白块闪烁。原因Coil对同一个content URI加载图片时会走系统缩略图逻辑但视频封面在MediaStore里生成缩略图的成本比图片高首次加载需要从视频文件中抽帧并压缩。视频数量越多IO压力越大主线程等待时间越长掉帧就越明显。解决分两层处理。第一层给Coil的ImageRequest设置size(300, 300)强制加载小尺寸缩略图避免系统生成超大封面第二层把Coil的磁盘缓存策略打开让首次加载的结果落盘二次滚动直接读缓存。这个案例提醒了一个通用规律媒体应用的性能瓶颈九成在图片加载层而不是在列表渲染层。6. 二次开发把默认图库改成自己的私有相册拿到这份源码之后最值得做的改造是给它加上收藏夹能力这一步能让应用的实用性立刻上一个台阶。思路是在现有架构上加一个独立的本地存储层用Room数据库建一张favorite表字段只有mediaId和mediaTypeUI层在网格Item上追加一个收藏按钮点击后把对应的MediaItem写入数据库。改造步骤分三步走。第一步在Gradle里引入Room的依赖写Entity和Dao第二步在GalleryViewModel里注入收藏数据库实例添加toggleFavorite方法第三步在MediaThumbnail的点击事件里调用ViewModel同时用AnimatedVisibility在网格右上角显示一个心形图标。整个过程不需要动MediaRepository因为收藏只依赖MediaItem的id和type数据边界很清晰。第二个值得做的是自定义排序。现在只支持时间倒序如果你想按文件大小排序只需要在MediaRepository里加一个排序参数suspend fun loadImages(sortBy: SortBy): ListMediaItem withContext(Dispatchers.IO) { val sortOrder when (sortBy) { SortBy.DATE - ${MediaStore.Images.Media.DATE_ADDED} DESC SortBy.SIZE - ${MediaStore.Images.Media.SIZE} DESC SortBy.NAME - ${MediaStore.Images.Media.DISPLAY_NAME} ASC } // 其余查询逻辑相同 }改完之后UI层用一个下拉菜单绑定SortBy的枚举值状态变化时重新触发loadMedia。这个改动训练的是你理解查询层与UI层解耦的能力也是后续加搜索功能的地基。验证这块我会在Android 12、Android 14两台真机上分别跑一遍全流程重点看权限分支是否生效、收藏数据在应用重启后是否还在。每次改完记得跑一遍lint这个项目代码量小lint跑一遍只要几十秒能帮你提前发现资源引用和权限问题。做媒体应用这几年我吃过不少亏最大的教训是不要相信真机自带的相册等于MediaStore的全部行为不同厂商对MediaStore的实现有差异权限策略也不完全一致。从那以后我每次做媒体相关项目都强制走一遍真机矩阵测试至少一台Android 13以下设备、一台Android 13以上设备、一台国产定制ROM设备。这种不信文档信真机的习惯确实帮我避开过很多线上事故。希望这篇拆解能让你拿到源码后少走一段弯路也欢迎你把自己改造后的想法实践出来。本文还有配套的精品资源点击获取