Android 获取手机本地图片并显示:从权限申请到 ImageView 渲染的完整链路拆解
1. Android 读取本地相册图片并显示到 ImageView 的完整链路很多刚接触 Android 的朋友都会卡在同一个地方明明代码里写了查询相册真机跑起来却要么一张图都读不到要么 ImageView 一片空白要么直接闪退报权限错误。我自己第一次做「读取手机本地图片并显示」这个功能时也是折腾了大半天才把整条链路跑通。问题的核心在于Android 从 6.0 到 13、14存储权限模型改了好几轮网上很多老教程还在用READ_EXTERNAL_STORAGE加DATA字段取路径的写法放到新系统上自然跑不通。这篇文章要解决的就是这件事把「Android 获取手机本地图片并显示」从权限申请、MediaStore 查询、Content Uri 解析一直到 ImageView 渲染的完整链路拆开讲清楚。适合谁看适合已经会写 Activity、能跑起一个空项目但对 Android 存储权限和媒体库查询还比较模糊的开发者。读完之后你应该能拿到一份可以直接复制、在真机上跑通的最小闭环代码。整条链路我把它拆成四段第一段是权限决定你能不能读第二段是查询决定你能拿到哪些图片记录第三段是 Uri 解析决定你怎么把一条记录变成能加载的图片第四段是渲染决定图片怎么进 ImageView。任何一段断了界面就是空白。下面按这个顺序来每一段都给可复制的代码和真机验证方法。先明确一个关键认知从 Android 10API 29开始Google 推行分区存储Scoped Storage应用默认只能访问自己创建的媒体文件。要读取相册里其他应用创建的图片正规做法是通过 MediaStore 查询拿到content://开头的 Uri再用 ContentResolver 打开输入流解码。老教程里那种直接拿文件绝对路径BitmapFactory.decodeFile(path)的方式在新系统上要么返回 null要么直接抛异常。所以本文的代码会以 MediaStore Content Uri 为主线同时说明旧路径方式为什么不可靠。另外提醒一句读取相册属于敏感权限用户随时可能拒绝。所以权限申请必须做「拒绝后再次请求」和「永久拒绝后引导去设置页」的处理否则用户点了拒绝你的界面就永远空白还找不到原因。这部分我会在权限章节给出完整处理逻辑。2. 权限声明与运行时申请Android 13 前后差异权限是整条链路的第一道门。门没开后面查询代码写得再漂亮也没用。这里最大的坑是Android 13API 33把读取媒体文件的权限拆分了READ_EXTERNAL_STORAGE在 13 及以上不再生效取而代之的是READ_MEDIA_IMAGES。如果你只在 Manifest 里声明了旧权限在 Android 13 真机上查询相册会直接返回空游标。先看 Manifest 声明。为了兼容不同版本两个权限都要写上系统会按版本自动选用manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.localimage !-- Android 12 及以下使用 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / !-- Android 13 及以上使用 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / application android:allowBackuptrue android:label本地图片Demo android:themestyle/Theme.AppCompat.Light activity android:name.ImageActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest注意READ_EXTERNAL_STORAGE上加了android:maxSdkVersion32这样在 Android 13 及以上系统不会请求这个已经失效的权限避免用户看到莫名其妙的授权弹窗。READ_MEDIA_IMAGES只针对图片如果你还要读视频或音频需要额外声明READ_MEDIA_VIDEO、READ_MEDIA_AUDIO。接下来是运行时申请。Android 6.0 以后危险权限必须动态申请。我把它封装成一个方法根据系统版本选择要请求的权限数组private static final int REQUEST_CODE_PERMISSION 10001; private String[] getRequiredPermissions() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13 return new String[]{Manifest.permission.READ_MEDIA_IMAGES}; } else { return new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}; } } private boolean hasPermission() { for (String p : getRequiredPermissions()) { if (ContextCompat.checkSelfPermission(this, p) ! PackageManager.PERMISSION_GRANTED) { return false; } } return true; } private void requestPermissionIfNeeded() { if (hasPermission()) { loadImages(); } else { ActivityCompat.requestPermissions(this, getRequiredPermissions(), REQUEST_CODE_PERMISSION); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQUEST_CODE_PERMISSION) { boolean allGranted grantResults.length 0; for (int result : grantResults) { if (result ! PackageManager.PERMISSION_GRANTED) { allGranted false; break; } } if (allGranted) { loadImages(); } else { // 用户拒绝判断是否勾选了「不再询问」 boolean shouldExplain false; for (String p : getRequiredPermissions()) { if (ActivityCompat.shouldShowRequestPermissionRationale(this, p)) { shouldExplain true; break; } } if (shouldExplain) { new AlertDialog.Builder(this) .setTitle(需要相册权限) .setMessage(读取本地图片需要访问相册请授权后重试。) .setPositiveButton(重新授权, (d, w) - requestPermissionIfNeeded()) .setNegativeButton(取消, null) .show(); } else { // 永久拒绝引导去设置页 new AlertDialog.Builder(this) .setTitle(权限已被永久拒绝) .setMessage(请到系统设置中手动开启相册权限。) .setPositiveButton(去设置, (d, w) - { Intent intent new Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.fromParts(package, getPackageName(), null)); startActivity(intent); }) .setNegativeButton(取消, null) .show(); } } } }这里有个容易忽略的点shouldShowRequestPermissionRationale返回 false 有两种可能一种是首次请求还没弹过另一种是用户勾了「不再询问」。所以判断永久拒绝要结合「是否已经请求过」这个状态简单做法是用 SharedPreferences 记一个标记。上面代码为了简洁在拒绝分支里直接按永久拒绝处理实际项目建议加上标记判断。权限这块踩过的坑还有一个有些国产 ROM 即使你声明了READ_MEDIA_IMAGES首次查询相册仍可能返回空需要用户手动在设置里给「照片和视频」权限。这不是代码问题是系统定制行为测试时留意一下。3. MediaStore 查询与 Content Uri 解析可复制配置权限拿到后进入查询环节。核心是用 ContentResolver 查询MediaStore.Images.Media.EXTERNAL_CONTENT_URI拿到游标后逐行读取。这里要特别注意新系统上不要再读MediaStore.Images.Media.DATA字段去取绝对路径这个字段在 Android 10 以后可能为 null 或不可用。正确做法是读_ID拼成content://media/external/images/media/{id}这样的 Content Uri。先定义一个实体类只存必要信息public class ImageItem { private final long id; private final String name; private final Uri uri; public ImageItem(long id, String name, Uri uri) { this.id id; this.name name; this.uri uri; } public long getId() { return id; } public String getName() { return name; } public Uri getUri() { return uri; } }然后是查询方法。我用_ID和DISPLAY_NAME两列按DATE_ADDED倒序只取前 30 张避免一次加载太多导致卡顿private final ListImageItem imageList new ArrayList(); private void loadImages() { imageList.clear(); String[] projection { MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME }; String sortOrder MediaStore.Images.Media.DATE_ADDED DESC; try (Cursor cursor getContentResolver().query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder)) { if (cursor null) { Log.e(ImageActivity, query 返回 null); return; } int idCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID); int nameCol cursor.getColumnIndexOrThrow( MediaStore.Images.Media.DISPLAY_NAME); int count 0; while (cursor.moveToNext() count 30) { long id cursor.getLong(idCol); String name cursor.getString(nameCol); Uri contentUri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); imageList.add(new ImageItem(id, name, contentUri)); count; } } catch (Exception e) { Log.e(ImageActivity, 查询相册失败, e); } Log.d(ImageActivity, 共查询到 imageList.size() 张图片); adapter.notifyDataSetChanged(); }这里用ContentUris.withAppendedId把_ID拼成完整 Uri比手动字符串拼接安全。查询出来的 Uri 形如content://media/external/images/media/12345这就是后面加载图片要用的地址。如果你确实需要兼容旧代码里的路径方式可以读DATA列但一定要判空int dataCol cursor.getColumnIndex(MediaStore.Images.Media.DATA); String path dataCol 0 ? cursor.getString(dataCol) : null; if (path ! null new File(path).exists()) { // 旧方式仅作兼容 }不过我要强调新项目不要依赖这个路径。分区存储下即使拿到了路径你的应用也未必有权限直接读那个文件。Content Uri 才是官方推荐、跨版本稳定的方式。关于查询性能还有两个实用参数。一是setNotificationUri可以让游标在媒体库变化时自动刷新适合做实时相册二是分页查询用LIMIT配合Bundle传给 query避免一次性加载几千张图。本文为了最小闭环先用固定 30 张。4. 真机验证从 Content Uri 到 ImageView 渲染查询拿到 Uri 列表后最后一步是渲染。这里最容易犯的错是直接在主线程用BitmapFactory.decodeStream解码大图结果 OOM 或者界面卡死。正确做法是先按目标尺寸采样解码再设置给 ImageView如果列表较长还要考虑异步加载和缓存。先写一个按目标宽高采样解码的工具方法private Bitmap decodeSampledBitmap(Uri uri, int reqWidth, int reqHeight) { try { // 第一次只读边界不分配像素内存 BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; try (InputStream is getContentResolver().openInputStream(uri)) { BitmapFactory.decodeStream(is, null, options); } // 计算采样率 options.inSampleSize calculateInSampleSize(options, reqWidth, reqHeight); options.inJustDecodeBounds false; // 第二次真正解码 try (InputStream is getContentResolver().openInputStream(uri)) { return BitmapFactory.decodeStream(is, null, options); } } catch (Exception e) { Log.e(ImageActivity, 解码失败: uri, e); return null; } } private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { int height options.outHeight; int width options.outWidth; int inSampleSize 1; while (height / inSampleSize reqHeight || width / inSampleSize reqWidth) { inSampleSize * 2; } return inSampleSize; }采样率按 2 的幂次递增这是官方推荐做法能保证解码质量。inJustDecodeBounds第一次只读宽高不占内存第二次才真正解码这个两步法是避免 OOM 的关键。然后是适配器。为了演示清晰我用一个简单的 BaseAdapter在getView里做异步加载public class ImageAdapter extends BaseAdapter { private final Context context; private final ListImageItem data; private final LayoutInflater inflater; public ImageAdapter(Context context, ListImageItem data) { this.context context; this.data data; this.inflater LayoutInflater.from(context); } Override public int getCount() { return data.size(); } Override public Object getItem(int position) { return data.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView inflater.inflate(R.layout.item_image, parent, false); holder new ViewHolder(); holder.image convertView.findViewById(R.id.image_img); holder.name convertView.findViewById(R.id.image_name); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } ImageItem item data.get(position); holder.name.setText(item.getName()); holder.image.setTag(item.getUri()); holder.image.setImageBitmap(null); // 简单异步加载实际项目建议用 Glide/Coil final ImageView target holder.image; final Uri uri item.getUri(); new Thread(() - { Bitmap bm decodeSampledBitmap(uri, 300, 300); target.post(() - { if (uri.equals(target.getTag())) { target.setImageBitmap(bm); } }); }).start(); return convertView; } static class ViewHolder { ImageView image; TextView name; } }这里用setTag存 Uri在异步回调里比对防止列表复用导致图片错位。这是 ListView/RecyclerView 异步加载的经典处理。实际项目里我强烈建议直接用 Glide 或 Coil一行代码搞定采样、缓存、生命周期本文手写是为了让你看清底层链路。布局文件item_image.xml很简单一个 ImageView 加一个 TextView?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding8dp ImageView android:idid/image_img android:layout_width200dp android:layout_height200dp android:scaleTypecenterCrop android:contentDescription本地图片 / TextView android:idid/image_name android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop4dp android:maxLines1 android:ellipsizemiddle / /LinearLayoutActivity 的onCreate里把 ListView 和适配器接上然后调用requestPermissionIfNeeded()。真机验证步骤装到 Android 13 手机上首次启动会弹「允许访问照片和视频」点允许列表应该显示最近 30 张图点拒绝应该弹出引导对话框去设置里手动开启后再回来图片正常显示。如果列表空白先看 Logcat 里「共查询到 X 张图片」这行日志X 为 0 说明是权限或查询问题X 大于 0 但界面空白说明是渲染问题。5. 常见报错排查从 SecurityException 到空白 ImageView这一节把真机上最常撞到的几个报错列出来对照着排查能省不少时间。第一个是java.lang.SecurityException: Permission Denial: reading MediaProvider。这个报错说明权限没拿到就发起了查询。检查两点Manifest 里是否声明了对应版本的权限运行时是否真的申请并拿到了。特别注意 Android 13 上如果只声明了READ_EXTERNAL_STORAGE查询会直接抛这个异常。解决就是按第 2 节的版本判断逻辑13 及以上用READ_MEDIA_IMAGES。第二个是查询返回空游标Logcat 里「共查询到 0 张图片」。可能原因有三个权限被拒绝但没抛异常部分 ROM 行为设备相册确实为空查询的 Uri 用错了。先确认权限状态再确认MediaStore.Images.Media.EXTERNAL_CONTENT_URI没写错。如果是模拟器默认相册可能是空的先往模拟器里拖几张图再测。第三个是BitmapFactory.decodeStream返回 null。常见于 Uri 失效或输入流被关闭。检查openInputStream是否抛了FileNotFoundException以及 Uri 是否是有效的content://地址。如果是从旧代码迁移过来用了file://路径在 Android 7 以上会触发FileUriExposedException必须换成 Content Uri 或配置 FileProvider。第四个是OutOfMemoryError。这是没做采样解码直接把几千万像素的原图读进内存。按第 4 节的两步采样法处理或者直接用 Glide。另外注意inSampleSize计算时不要返回 0最小为 1。第五个是图片错位。列表快速滑动时某张图显示成了另一张。原因是异步加载回调没有校验当前 Item 对应的 Uri。用setTag 比对的方式解决或者用 Glide 自带的占位和取消机制。第六个是 Android 14 上的部分照片权限。Android 14 引入了「选择部分照片」的授权方式用户可能只授权了部分图片。这种情况下查询结果只包含被授权的图片属于正常行为。如果你的应用需要全部图片要在引导里说明用途让用户选择「允许全部」。排查时有个通用技巧在查询和渲染的关键节点都打 Log包括权限状态、游标行数、Uri 值、解码结果。真机连上 Android Studio 看 Logcat比盲猜快得多。如果 Logcat 里连查询日志都没有说明代码根本没走到查询问题在权限申请或生命周期。6. 把链路跑通之后工程化与后续优化最小闭环跑通后你会发现手写这套代码在真实项目里维护成本不低权限逻辑要兼容多个版本异步加载要处理生命周期列表复用要防错位大图要防 OOM。所以工程化的第一步通常是把图片加载交给成熟库。Glide 的接入只需要一行Glide.with(context) .load(item.getUri()) .placeholder(R.drawable.ic_placeholder) .centerCrop() .into(holder.image);它会自动处理采样、缓存、生命周期绑定和列表复用比手写可靠得多。权限部分则可以用 ActivityResultContracts 的新 API比onRequestPermissionsResult更简洁private final ActivityResultLauncherString permissionLauncher registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted - { if (granted) loadImages(); else showPermissionDeniedDialog(); });如果你的应用还要支持「拍照后显示」「裁剪后显示」链路会再长一段拍照用ACTION_IMAGE_CAPTURE配合 FileProvider 生成目标 Uri裁剪用ACTION_CROP结果同样通过 Content Uri 回传。核心思路和本文一致都是围绕 Uri 做读写只是多了跨应用传递 Uri 的权限授予FLAG_GRANT_READ_URI_PERMISSION。另外如果你的项目涉及在 Android 端接入大模型能力比如让用户选一张本地图片后调用多模态模型做识别或描述那么图片读取这条链路就是前置步骤。选好图拿到 Uri 后通常需要把图片转成 Base64 或上传到服务端再调用模型接口。这类场景下模型 API 的接入和 Key 管理可以借助统一的平台来处理比如 TaoToken 提供了兼容 OpenAI 风格的接口模型对话入口在 https://taotoken.net/api-keys 接入文档在 https://taotoken.net/doc 需要长期跑编码或 Agent 任务的可以看 Coding Planhttps://taotoken.net/coding-plan 。把本地图片读取和模型调用串起来就能做出「选图—识别—展示结果」的完整功能。回到本文主题Android 获取本地图片并显示这条链路说到底就是四件事权限要对版本、查询要用 MediaStore、地址要用 Content Uri、渲染要做采样。把这四点做扎实真机上基本不会出问题。剩下的就是根据项目需要决定是手写还是交给 Glide 这类库。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →