资讯动态

通讯录相册短信读取源码:Android权限适配与数据封装解析

发布时间:2026/10/14 1:14:15 来源:尧图企业网站定制
简介这是一套2023年发布的通讯录、相册与短信管理完整源码覆盖Android与iOS双端附带服务器后台与数据库定位为移动通信类项目的学习与二次开发素材。压缩包共5个文件总计20.09MBAndroid安装包apk与iOS应用包ipa分别对应双端客户端sql脚本用于初始化短信、联系人等业务数据表rar压缩包内含txl后台服务代码txt文档则提供完整搭建教程各文件职责明确可按需单独查阅或整体部署。已有2628人浏览学习该源码并非简单的界面示例而是实现了视频通话、联系人管理、相册展示等核心能力并预留后端接口供二次扩展。开发者可借助模拟器快速安装调试对比sql与后台代码梳理数据流向理解前后端交互和跨平台打包逻辑也可依据txt教程从零搭建环境复现短信发送、相册上传等场景。整体而言这是一份既能上手运行又可深入研读的通信类项目源码尤其适合初中级开发者用于实战练习与技术进阶。1. 通讯录相册短信源码包先看它解决了什么再决定怎么跑作为一份带着“研究”标签的通讯录相册短信源码包它不承诺让你一夜之间做出一个“生活助手”App但它把三块最容易被反复询问的数据读取场景放到了同一套工程里通讯录联系人读取、相册媒体扫描、短信收发解析。门槛卡在权限适配和数据源封装上——绝大多数人不是不会写 UI而是搞不定 Android 6 之后的运行时权限、分区存储带来的路径假象。这套源码适合两类人一是打算做联系人备份、短信备份、相册整理类工具的开发者二是需要一份可运行 Demo 做二次开发的在校生。它的价值不在于代码量多大而在于帮你把“读数据”这条链路上最容易翻车的细节提前暴露出来。2. 通讯录模块权限申请与联系人字段的读取边界2.1 运行时权限为什么这块最容易一跑就崩拿到源码后先不要急着跑。先看AndroidManifest.xml里有没有声明READ_CONTACTS。声明了权限只是第一步Android 6.0API 23之后危险权限必须运行时动态申请。很多人的“崩溃点”不在权限本身而在没有处理用户拒绝后的回调。常见做法是封装一个权限工具类把requestPermissions和onRequestPermissionsResult收拢到同一个 Activity 基类里。源码里如果直接在每个页面重复写授权逻辑后续维护成本很高。public class ContactPermissionHelper { private static final int REQ_CONTACT 1001; private final Activity activity; private Runnable onGranted; public ContactPermissionHelper(Activity activity) { this.activity activity; } public void request(Runnable granted) { this.onGranted granted; if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { granted.run(); // 6.0 以下安装时已授权 return; } if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CONTACTS) PackageManager.PERMISSION_GRANTED) { granted.run(); } else { ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.READ_CONTACTS}, REQ_CONTACT); } } public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { if (requestCode REQ_CONTACT grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { if (onGranted ! null) onGranted.run(); } else { Toast.makeText(activity, 未授权联系人读取, Toast.LENGTH_SHORT).show(); } } }这段代码的逻辑不复杂先判断系统版本Android 6.0 以下不需要动态申请6.0 以上先查checkSelfPermission没授权就走requestPermissions。参数里REQ_CONTACT是请求码用于在回调中区分是哪个权限的申请结果permissions数组可以一次申请多个权限但回调里要逐个检查grantResults。这里需要注意很多源码只处理了授权成功没处理拒绝后二次点击授权的情况。真机上用户一旦选了“拒绝并不再询问”下次requestPermissions不会再弹窗。要跳转到设置页让用户手动打开这才是完整的闭环。源码包里如果没有这一段建议自己补上。2.2 联系人解析从 Cursor 到实体类的字段映射联系人表的结构和大多数人的直觉不一样ContactsContract.Contacts表存的是联系人聚合信息而电话号码、邮箱这类具体数据分散在Phone.CONTENT_URI里。一个联系人可能有多个号码、多个邮箱不能只查一次就完了。源码里如果直接遍历ContactsContract.Contacts然后去拿NUMBER字段你会得到一个长期报错的空指针。正确做法是分两层查先查出联系人 ID 和显示名再按 ID 去Phone.CONTENT_URI查号码列表。public class ContactReader { private ContentResolver resolver; public ContactReader(ContentResolver contentResolver) { this.resolver contentResolver; } public ListContactEntity readAll() { ListContactEntity list new ArrayList(); Cursor contacts resolver.query(ContactsContract.Contacts.CONTENT_URI, new String[]{ ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.HAS_PHONE_NUMBER }, null, null, null); if (contacts ! null) { while (contacts.moveToNext()) { String contactId contacts.getString(0); String name contacts.getString(1); int hasPhone contacts.getInt(2); ContactEntity entity new ContactEntity(contactId, name); if (hasPhone 0) { entity.phones readPhones(contactId); } list.add(entity); } contacts.close(); } return list; } private ListString readPhones(String contactId) { ListString phones new ArrayList(); Cursor phoneCursor resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, new String[]{ContactsContract.CommonDataKinds.Phone.NUMBER}, ContactsContract.CommonDataKinds.Phone.CONTACT_ID ?, new String[]{contactId}, null); if (phoneCursor ! null) { while (phoneCursor.moveToNext()) { phones.add(phoneCursor.getString(0)); } phoneCursor.close(); } return phones; } }这个实现的选型理由是利用HAS_PHONE_NUMBER先过滤掉没有电话号码的联系人减少一次子查询。参数说明里有两个容易踩的坑第一游标用完必须close()否则长时间运行会泄漏第二readPhones里用了CONTACT_ID ?做参数化查询这里不能直接拼字符串联系人 ID 虽然一般不是恶意内容但参数化查询是防止 SQL 注入的底线习惯。源码包的具体字段名可能略有差异核心逻辑就这一套——联系人是“一对多”结构别按单行数据处理。模拟器里没有联系人数据时可以在通讯录里手动添加几条带手机号的联系人再跑这段代码验证。3. 相册扫描模块MediaStore 去重、增量更新与缩略图缓存3.1 全量扫描改成增量扫描封面不重、列表不卡相册模块的常见实现是直接MediaStore.Images.Media.EXTERNAL_CONTENT_URI查询所有图片按时间倒序显示。这样做的问题在于图库里有几千张图时每次进入相册页都全量扫描滑动列表时会出现卡顿缩略图也会重复加载。源码包里如果也只是简单query需要改造成“增量扫描”方案。增量扫描的核心理念是只拿发生变化的那部分数据而不是每次把整张表拉出来。-- 查询最近 7 天内新增或修改过的图片 SELECT _ID, DATA, DATE_ADDED, DATE_MODIFIED, SIZE, MIME_TYPE FROM images WHERE DATE_ADDED (SELECT strftime(%s, now, -7 days)) OR DATE_MODIFIED (SELECT strftime(%s, now, -7 days)) ORDER BY DATE_ADDED DESC;这段 SQL 模拟的是“增量”思路但 Android 里不能直接写裸 SQL要封装成MediaStore的查询参数。具体做法是构造selection条件第一个参数传时间戳阈值long threshold System.currentTimeMillis() / 1000 - 7 * 24 * 3600; String selection MediaStore.Images.Media.DATE_ADDED ?; String[] args new String[]{String.valueOf(threshold)}; Cursor cursor resolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[]{MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_MODIFIED}, selection, args, MediaStore.Images.Media.DATE_ADDED DESC);这段代码的关键在于时间阈值的单位。DATE_ADDED的单位是秒所以从毫秒时间戳转换成秒必须除以 1000。如果漏了这一步threshold会是一个很大的数查询结果会空掉。另一个问题是DATA字段在 Android 10 分区存储之后已经不可靠它返回的是应用私有目录之外的路径直接拿来当File用大概率 IO 出错。正确做法是用ContentResolver.openInputStream(uri)去读。增量改造后首次启动仍然全量扫但之后每次进页面只扫最近增量封面图和列表项就不至于反复刷新。3.2 缩略图与缓存策略内存占用降一个量级相册列表最吃内存的不是图片本身而是 ImageView 加载原图导致的 OOM。源码里如果用了BitmapFactory.decodeFile直接加载显示滑动几屏必翻车。要改造为三级策略第一层使用MediaStore.Images.Thumbnails查询系统生成的缩略图第二层把缩略图文件缓存到应用私有目录避免每次重启都重新生成第三层列表滑动停止后再加载图片加载时按ImageView的实际尺寸做采样压缩。public Bitmap loadThumbnail(Context context, long imageId, int targetWidth) { ContentResolver resolver context.getContentResolver(); BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; // 先只读边界 BitmapFactory.decodeStream( resolver.openInputStream( ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageId)), null, options); // 计算采样率目的让图片宽度接近 targetWidth int sampleSize 1; while (options.outWidth / (sampleSize * 2) targetWidth) { sampleSize * 2; } options.inSampleSize sampleSize; options.inJustDecodeBounds false; return BitmapFactory.decodeStream( resolver.openInputStream( ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageId)), null, options); }这个函数的选型原因是避免一次加载整张原图到内存。inJustDecodeBounds首次解码不申请内存只拿宽高然后通过inSampleSize做二次幂缩放。参数说明targetWidth通常设成列表项 ImageView 的宽度再乘 1.5 倍太高浪费内存太低图片发虚——我一般取 200300 像素。如果源码本身提供了图片加载框架比如 Glide那么可以在配置里加上.thumbnail(0.1f)让框架先显示一个低分辨率版本再加载高清图这个参数是“占位缩略图比例”0.1 表示先加载原图 10% 大小的版本体验上感知不到延迟。相册模块跑起来后用 Android Studio 的 Profiler 看一下内存占用如果数值稳定在 80M 以内说明缓存策略基本合格。4. 短信模块读取、分类与本地导出4.1 短信解析对话分组和时间戳排序短信数据源的坑比联系人还要多。Telephony.Sms表里每条短信是一个独立行但界面通常按会话Conversation展示。所谓会话就是同一个thread_id下的所有短信聚合。源码里如果直接把所有短信列出来那几乎没法用。正确路径是先查询Telephony.Sms.CONVERSATION_URI或直接查Telephony.Sms表按thread_id分组然后在脑子把“联系人”和“线程”关联起来。如果有READ_SMS权限读取本身不难难在排序和去重。public ListSmsEntity readSmsByThread(long threadId) { ListSmsEntity result new ArrayList(); Cursor cursor resolver.query(Telephony.Sms.CONTENT_URI, new String[]{ Telephony.Sms.ADDRESS, Telephony.Sms.BODY, Telephony.Sms.DATE, Telephony.Sms.TYPE, Telephony.Sms.THREAD_ID}, Telephony.Sms.THREAD_ID ?, new String[]{String.valueOf(threadId)}, Telephony.Sms.DATE ASC); if (cursor ! null) { int addrIdx cursor.getColumnIndex(Telephony.Sms.ADDRESS); int bodyIdx cursor.getColumnIndex(Telephony.Sms.BODY); int dateIdx cursor.getColumnIndex(Telephony.Sms.DATE); int typeIdx cursor.getColumnIndex(Telephony.Sms.TYPE); while (cursor.moveToNext()) { SmsEntity entity new SmsEntity(); entity.address cursor.getString(addrIdx); entity.body cursor.getString(bodyIdx); entity.date cursor.getLong(dateIdx); entity.type cursor.getInt(typeIdx); result.add(entity); } cursor.close(); } return result; }这段代码的排序逻辑里DATE字段按ASC升序拿到的就是会话内从早到晚的对话流用来渲染气泡聊天页刚好合适。TYPE字段取值含义要记清楚1 表示收到2 表示发出。源码里如果要显示“我发出去的是什么样”需要用TYPE 2做判断。有一点容易被忽略ADDRESS字段在双卡手机上可能只显示主卡号码或者显示类似8613x...的带区号格式做归属地匹配前先归一化把空格、横线、86前缀都去掉否则后续分类统计会漏掉一批号码。模拟器上没有真实短信可以用 adb 往模拟器推送一条假短信再跑测试我在后面章节给具体命令。4.2 分类统计与导出把数据落到本地文件短信模块另外一个常见功能是“按号码统计”和“导出备份”。源码里如果只做了读取展示导出功能通常是被砍掉或者注释的。自己补的话核心逻辑是遍历所有线程统计每个号码的短信条数。这里要注意一个号码可能对应多个thread_id尤其是群聊和通知短信。最佳方案是建一个以“归一化号码”为 key 的 Map把重复号码的统计累加起来同时记录最后一条短信的时间作为“最近联系时间”。public MapString, SmsStat groupByAddress(ListSmsEntity allSms) { MapString, SmsStat statMap new HashMap(); for (SmsEntity sms : allSms) { String key normalizeAddress(sms.address); SmsStat stat statMap.get(key); if (stat null) { stat new SmsStat(key, 0, sms.date); statMap.put(key, stat); } stat.count; if (sms.date stat.lastDate) { stat.lastDate sms.date; } } return statMap; } private String normalizeAddress(String address) { if (address null) return ; String cleaned address.replace( , ).replace(-, ); if (cleaned.startsWith(86) cleaned.length() 3) { cleaned cleaned.substring(3); } return cleaned; }这段的逻辑核心在normalizeAddress。它把带区号、带空格、带横杠的号码统一成 11 位手机号统计 Map 才不会被同一个号码的不同写法拆成多个条目。参数说明lastDate用来排序“最近联系人”count用来排序“高频联系人”——这两个维度组合起来就是短信备份工具最常见的排名列表。导出功能建议写成 JSON 格式而不是 CSV因为短信内容可能包含换行和逗号CSV 转义容易出问题。JSON 序列化时正文里的换行符会被转义成\n读回来也方便。导出路径放到getExternalFilesDir或getCacheDir不要写死/sdcard/Download/——分区存储后公共目录写入需要额外的MANAGE_EXTERNAL_STORAGE权限报错率极高。5. 常见问题排查与避坑运行时权限、分区存储、时序竞态5.1 权限回调不触发、数据“假阳性”与进程被杀现象点击授权弹窗后App 没有进入预期页面或者权限明明拒绝了日志里仍然查到了联系人数据。原因前者通常是onRequestPermissionsResult写在了错误的 Activity 里或者授权逻辑被放在了 Fragment 中但回调写到了 Activity。后者是典型的“假阳性”——部分国产 ROM 在用户拒绝后会同步返回一个“仍可读”的旧缓存数据也有可能是源码用了PermissionsDispatcher这类注解框架生成代码时漏了NeedsPermission和OnShowRationale的配对。解决我一般会把权限请求和回调收敛到一个单独的PermissionActivity里只负责分发结果拿不到结果就直接杀掉页面重新进。同时在grantResults[0] PERMISSION_GRANTED之外再补一次checkSelfPermission结果双重确认。如果确认拒绝就用ActivityCompat.shouldShowRequestPermissionRationale判断是否能再次弹窗不能弹就引导去系统设置页修改。这个场景下调式时先看dumpsys package 包名 | grep runtime-permissions确认系统侧的真实授权状态再分析代码层面哪里掉了回调。5.2 Android 10 分区存储后路径全变成/storage/emulated/0假路径现象拿到图片的绝对路径/storage/emulated/0/DCIM/Camera/xxx.jpg构造new File(path)后exists()返回 true但openStream报 FileNotFoundException。原因分区存储后应用的DATA字段返回的是一个“视图路径”它看着像真实路径实际却不是应用可以直接读写的路径。exists()能返回 true是因为系统在兼容层做了映射但真正打开流时没有权限访问公共目录。解决放弃一切基于 file path 的操作全部改成ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)拿到Uri再配合ContentResolver.openInputStream读取。写文件也一样用MediaStore.insert插入后再拿拿到Uri去写入。源码包里如果大量出现File拼接路径这块需要重写。5.3 真机上短信条数永远等于 0现象明明手机里有一千条短信App 列表却空空如也。原因高版本 Android 对短信权限做了一次收紧READ_SMS不仅要动态申请部分设备还要求 App 被设置为“默认短信应用”否则读取接口直接返回空游标。有些定制 ROM 还会默认拦截非默认短信应用对短信数据库的读取日志里不报错但结果集是 0 条。解决检查是否设置了默认短信应用可以使用RoleManager或Telephony.Sms.getDefaultSmsPackage()判断。如果当前应用不是默认短信应用跳转去系统设置切换或者引导用户到“默认应用 短信”手动设置。这块是源码里最容易隐藏“空数据”的角落跑真机前先确认拥有默认短信角色。5.4 相册越扫越慢、内存持续增长现象首次启动 5 秒内出图滑到第 300 张时 App 越来越卡最后直接闪退。原因回收机制没有做透。RecyclerView 的 item 复用了但 ImageView 里加载的 Bitmap 没有释放或者缓存 Key 用的是文件路径同一张图片反复创建不同路径的 Key导致缓存永远不命中。解决用三方加载库的统一配置比如 Glide 里把缓存 Key 设置为content://形式的 Uri 字符串不要用 file 路径。自定义实现的话必须在onViewRecycled里主动Bitmap.recycle()但要注意 8.0 之后 Bitmap 是 Java 堆内存recycle()只是提前标记真正释放依赖 GC不要在 UI 线程频繁调用。还有一招是限制缩略图池大小用 LruCache 设置maxSize 0.1 * runtime.maxMemory()攻防之间可以稳定住。5.5 联系人 Detail 表查询造成主线程卡顿现象在“最近通话”页面快速滑动时联系人名字加载滞后界面白花花的。原因联系人的名字来自聚合表号码来自另一张表如果每条数据在主线程同步查一次子表遇到 500 个联系人就会卡成幻灯片。解决把“电话号码对照联系人”的映射一次性拉全放到HashMapString, String里后续所有页面都从内存取。具体实现是查询CommonDataKinds.Phone.CONTENT_URI的全部数据遍历时把号码做归一化处理再存 Map。这个是一次读写、多次取用的典型场景优化幅度能到两个量级。6. 源码验证的进阶技巧用 adb 权限矩阵跑一遍冒烟测试拿到源码后别光看代码静态分析要跑起来验证。我有一个习惯在模拟器和真机各跑一遍“权限开一半”的冒烟测试用 adb 动态撤销和授予权限观察 App 的响应是否和业务预期一致。这个测试不需要写任何 UI 自动化脚本只要搭配几个 adb 命令就能完成。# 安装 APK adb install -r app-debug.apk # 先模拟用户拒绝联系人权限 adb shell pm revoke 包名 android.permission.READ_CONTACTS # 再授予的完整组权限 adb shell pm grant 包名 android.permission.READ_CONTACTS adb shell pm grant 包名 android.permission.READ_EXTERNAL_STORAGE # 启动主界面 adb shell am start -n 包名/.MainActivity # 查看运行时权限的真实状态 adb shell dumpsys package 包名 | grep -A 5 runtime-permissions这段命令里我刻意做了三组操作revoke后先冷启动一次观察 App 是崩溃还是静默返回空列表这能验证页面有没有做权限检查的兜底逻辑grant后再冷启动一次验证授权后能不能顺利读到数据最后用dumpsys查看系统里对包名的授权矩阵防止模拟器权限状态和代码判断不一致。跑完这组命令后我再补一条内存检查在相册页反复滑动后执行adb shell dumpsys meminfo 包名 | grep -E TOTAL|Graphics|Native Heap重点看Graphics这一项有没有持续上涨如果三次采样数字差异在 10M 以内就说明缓存策略基本生效。短信模块的模拟数据也可以用命令推入——虽然高版本模拟器不提供直接的“发短信”注入命令但通过写数据库的方式也能造出几条测试记录再配合上面的权限矩阵跑一遍。这套源码包定位是“提供研究”意味着它更适合做深度拆解后自己重构而不是直接打包上架。如果你正打算做一个“通讯录 相册 短信”三合一的存储工具可以把它放上台面作为起点——把权限闭环补上、把 MediaStore 路径改为 Uri 操作、把短信读取做成默认短信应用适配剩下的界面和逻辑打磨反而没有那么复杂。从那以后我每次拿到这类带“研究”标签的源码都强制自己先走一遍权限矩阵和内存检查再决定要不要往下看业务代码省掉了很多后期的排查时间。希望帮到你少走几个弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑