☰
通讯录相册短信读取源码:Android权限适配与数据封装解析
2026/10/14 1:14:14 网站建设 项目流程

简介:这是一套2023年发布的通讯录、相册与短信管理完整源码,覆盖Android与iOS双端,附带服务器后台与数据库,定位为移动通信类项目的学习与二次开发素材。压缩包共5个文件,总计20.09MB:Android安装包(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.0(API 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 List<ContactEntity> readAll() { List<ContactEntity> 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 List<String> readPhones(String contactId) { List<String> 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 倍,太高浪费内存,太低图片发虚——我一般取 200~300 像素。如果源码本身提供了图片加载框架,比如 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 List<SmsEntity> readSmsByThread(long threadId) { List<SmsEntity> 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 Map<String, SmsStat> groupByAddress(List<SmsEntity> allSms) { Map<String, 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 个联系人就会卡成幻灯片。

解决:把“电话号码对照联系人”的映射一次性拉全,放到HashMap<String, 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 操作、把短信读取做成默认短信应用适配,剩下的界面和逻辑打磨,反而没有那么复杂。从那以后我每次拿到这类带“研究”标签的源码,都强制自己先走一遍权限矩阵和内存检查,再决定要不要往下看业务代码,省掉了很多后期的排查时间。希望帮到你,少走几个弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询