简介这是一套面向PHP后端开发者和移动端适配人员的双端获取通讯录与相册系统源码覆盖安卓App与苹果端相关文件。资源共2000个文件压缩包约51.06MB主体为1589个PHP业务逻辑文件并包含apk、ipa等双端安装包以及png、jpg图片资源、css/js前端样式脚本和md说明文档xlsx、sql等文件便于数据管理与部署配置。已有4618人学习下载。代码目录含后台管理入口、应用配置目录、运行目录与伪静态规则等整体按功能拆分适合需要快速搭建通讯录相册获取类系统、理解双端数据交互或进行二次开发的技术人员参考。作者随包提供了环境版本、数据库路径修改、运行目录设置、后台账号等部署说明安卓端反编译修改服务器IP的方法也一并给出有助于减少环境配置和联调阶段的踩坑时间。1. 换机备份的痛点这套双端相册通讯录获取系统源码能做什么手机里躺着几千张照片、几百条联系人换机那一刻最慌。这套 2023 双端相册通讯录获取系统源码给出的是一份能直接编译运行的 Android iOS 双工程在用户授权后把相册图像和通讯录条目读取出来落成本地的 VCF 文件与图片目录。这里的“获取”指的是合规读取本机数据不碰系统安全边界。适合两类人一是想给自己做备份工具、厌倦了换机助手导不全数据的开发者二是要在业务 App 里补一个“数据备份导出”模块、又不想从零啃权限矩阵的团队。拿到源码后可以直接改包名、换图标作为自用工具或产品底座。2. 项目拆解双端架构与数据流的起点拿到源码先别急着编译我把目录结构和数据约定讲透你才知道改哪里、怎么改。2.1 技术选型为什么双端都上原生而不走跨平台这套源码没有用 Flutter 或 uni-app而是老老实实分成 Android 和 iOS 两个独立工程。作为拆过这类项目的人我第一反应是选原生是对的。原因有三点。第一相册和通讯录是系统权限最敏感的两个模块Android 几乎一年换一次运行时权限规则iOS 的隐私描述也在收紧。跨平台框架对权限 API 的封装有滞后等插件更新往往要踩一个月坑。第二两端的读取链路差异太大Android 走 ContentResolver 和 MediaStoreiOS 走 ContactStore 和 PhotoKit强行抽象成一套公共层最终还是要为两端各写一套条件分支反而增加维护成本。第三导出细节上两端的“脾气”完全不同比如 iOS 的 HEIC 格式和 Android 的 DATA 字段废弃这些都需要直接操作系统 SDK 才能控制得干净。工程里实际的做法是在两端各抽出PermissionHelper和ExportService两个公共层。PermissionHelper统一处理权限申请的状态机ExportService对外暴露exportContacts()和exportImages()两个接口内部实现各写各的。这样业务层不用关心你在 Android 还是 iOS调用方式完全一致。2.2 源码目录两个工程各自管什么解压后能看到这样的目录结构dual-backup/ ├── android/ │ ├── app/ │ │ ├── src/main/ │ │ │ ├── java/com/dualbackup/ │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── permission/PermissionHelper.kt │ │ │ │ └── export/ │ │ │ │ ├── ContactExporter.kt │ │ │ │ └── ImageExporter.kt │ │ │ └── res/ │ │ └── build.gradle.kts ├── ios/ │ ├── BackupApp/ │ │ ├── Controllers/ │ │ │ └── ExportViewController.swift │ │ ├── Export/ │ │ │ ├── ContactExporter.swift │ │ │ └── ImageExporter.swift │ │ ├── Permission/ │ │ │ └── PermissionHelper.swift │ │ └── Info.plist │ └── BackupApp.xcodeproj └── README.md重点看export这个目录它是整套源码的核心。ContactExporter负责把系统通讯录转成 VCF 文本ImageExporter负责把系统相册里的图片拷贝到 App 沙盒目录。Android 端的MainActivity.kt只是搭了一个最简界面真正的逻辑都在后台 Service 里跑避免导出大量图片时把界面卡死。iOS 端对应的是ExportViewController.swift用 UIKit 写的逻辑同样很薄。permission目录是第二个重点。我见过太多备份类源码把权限申请散落在各个页面里最后用户拒绝一次就再也没机会补救。这套源码把权限收敛到一个 Helper 里每个入口申请前都会先查询当前状态再决定是直接执行、弹申请框还是引导去设置页。2.3 数据格式约定VCF 与按月份分桶的相册目录导出通讯录时源码统一输出 VCF 3.0 格式。这是手机和电脑通讯录通用的交换格式随便一个文本编辑器都能打开验证。每一条联系人的结构是这样BEGIN:VCARD VERSION:3.0 FN:张三 TEL;TYPECELL:13800138000 END:VCARDFN字段是显示名TEL;TYPECELL是手机号码标记。需要注意的是源码这里做了两层清洗空号码联系人直接跳过重复的显示名加序号后缀避免导入到目标设备时被识别成同一个人而合并。相册导出的约定更直白按拍摄月份建目录形如2023-01/、2023-02/。这样迁移到新手机后系统自动按年份和月份聚合不会出现全部照片堆在一个文件夹里的“照片坟场”。文件命名用原始文件名加上哈希后缀避免不同月份出现同名文件互相覆盖。这套约定虽然简单但在真实换机场景里非常顶用我拆过的不少付费换机工具内部打包结构也就是这个思路。3. 核心模块实现从系统 API 到 VCF 与原图文件的完整链路这一章是整个源码最值得抄作业的部分。我按 Android 和 iOS 分开讲每一段都给出关键代码和参数说明。3.1 Android 通讯录一次 query 导出完整 VCFAndroid 读取通讯录的核心是ContactsContract.CommonDataKinds.Phone这个表。它把联系人和电话号码做了扁平化一行就是一条“姓名 电话”的记录。源码里的ContactExporter.kt是这样写的class ContactExporter { fun exportContacts(context: Context): String { val uri ContactsContract.CommonDataKinds.Phone.CONTENT_URI val projection arrayOf( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER ) val vcf StringBuilder() context.contentResolver.query(uri, projection, null, null, null)?.use { cursor - while (cursor.moveToNext()) { val name cursor.getString(0) val number cursor.getString(1) if (number.isBlank()) continue vcf.append(BEGIN:VCARD\n) vcf.append(VERSION:3.0\n) vcf.append(FN:${name}\n) vcf.append(TEL;TYPECELL:${number}\n) vcf.append(END:VCARD\n) } } return vcf.toString() } }这段代码里uri是系统提供的内容提供器地址相当于数据库表projection声明你只取两列联系人姓名和电话号码。query返回的cursor是一个可关闭迭代器use关键字保证遍历完自动关闭不泄露游标。这里有两个参数细节值得说。第一cursor.getString(0)和getString(1)的下标必须和projection数组顺序一致一旦调整了数组顺序而忘了调整下标轻则空指针重则把姓名和电话错位导出。第二空号码跳过不能省真实通讯录里大量存在只有邮箱没有电话的条目硬导进 VCF 会在目标手机上显示一堆空白联系人。这段代码的逻辑就是逐行读、拼 VCF 字符串最后整体返回。如果联系人超过一千条建议改成流式写入文件避免内存里攒一个很大的字符串。3.2 Android 相册MediaStore 查询与文件安全的第三种方式Android 相册读取在 API 29 之后变成了一件需要小心处理的事。源码里的ImageExporter.kt用了一个比较稳妥的写法fun exportImages(context: Context, outputDir: File): Int { val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME ) val collection if (Build.VERSION.SDK_INT 29) { MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) } else { MediaStore.Images.Media.EXTERNAL_CONTENT_URI } var count 0 context.contentResolver.query(collection, projection, null, null, null)?.use { cursor - val idColumn cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val nameColumn cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) while (cursor.moveToNext()) { val id cursor.getLong(idColumn) val name cursor.getString(nameColumn) val uri ContentUris.withAppendedId(collection, id) context.contentResolver.openInputStream(uri)?.use { input - val outFile File(outputDir, name) outFile.outputStream().use { output - input.copyTo(output) } count } } } return count }这段代码的关键在于查询只拿_ID和DISPLAY_NAME不碰DATA路径字段。因为 Android 10 之后直接读文件路径经常拿不到正确姿势是拿到_ID后拼出content://形式的 URI再通过openInputStream打开。这里有一个反直觉的坑如果你在 API 29 以上用了MediaStore.Images.Media.EXTERNAL_CONTENT_URI这个旧常量虽然不报错但它只扫描“外部存储根目录”下的图片可能漏掉 DCIM 里按月份分的子目录。源码特意在 API 29 分支用getContentUri(MediaStore.VOLUME_EXTERNAL)这个调用会返回所有外部存储卷覆盖范围更全。文件名直接用DISPLAY_NAME如果担心重名可以在前面拼上_ID比如12345_IMG_001.jpg。3.3 iOS 通讯录CNContactStore 枚举与权限回调iOS 端读取通讯录用的是Contacts框架。源码里的ContactExporter.swift核心逻辑如下import Contacts func exportContacts() - String { let store CNContactStore() let keys: [CNKeyDescriptor] [ CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey ] let request CNContactFetchRequest(keysToFetch: keys) var vcf try? store.enumerateContacts(with: request) { contact, _ in let family contact.familyName let given contact.givenName let name family given guard let phone contact.phoneNumbers.first else { return } let number phone.value.stringValue vcf BEGIN:VCARD\n vcf VERSION:3.0\n vcf FN:\(name)\n vcf TEL;TYPECELL:\(number)\n vcf END:VCARD\n } return vcf }keys数组是你要读取的字段白名单这里只取了姓、名、电话。iOS 的通讯录每条联系人可以有多个电话源码只取了第一个如果你需要导出全部号码可以把phoneNumbers整个遍历一遍。enumerateContacts是同步遍历回调里拿到contact对象后直接拼字符串。需要注意try?把错误吞了。如果用户没授权这里不会崩但enumerateContacts会直接抛错最终拿到的 VCF 是空字符串。所以调用这个函数之前必须确保已经拿到通讯录授权否则会在导出页面看到一个“成功”却没有内容的 VCF。我建议把try?改成do-catch并把错误抛给上层至少让界面能提示“权限不足”。3.4 iOS 相册PhotoKit 原图导出与 HEIC 处理iOS 相册的读取不是直接给一个文件路径而是通过PHAsset对象去请求资源。源码里的ImageExporter.swift这样处理import Photos func exportImages(to directory: URL) { let options PHFetchOptions() let assets PHAsset.fetchAssets(with: .image, options: options) let manager PHImageManager.default() let requestOptions PHImageRequestOptions() requestOptions.isSynchronous true requestOptions.resizeMode .none requestOptions.deliveryMode .highQualityFormat assets.enumerateObjects { asset, _, _ in manager.requestImageDataAndOrientation(for: asset, options: requestOptions) { data, _, _, _ in guard let data data else { return } var filename asset.value(forKey: filename) as? String ?? photo.jpg if filename.hasSuffix(.HEIC) || filename.hasSuffix(.heic) { filename (filename as NSString).deletingPathExtension .jpg if let jpegData UIImage(data: data)?.jpegData(compressionQuality: 0.9) { try? jpegData.write(to: directory.appendingPathComponent(filename)) return } } try? data.write(to: directory.appendingPathComponent(filename)) } } }PHFetchOptions是查询条件这里传空就表示抓取全部图片。requestImageDataAndOrientation会返回原始数据的字节流resizeMode .none表示不要裁剪缩放保证导出的是原图。源码里单独处理了 HEIC 转 JPEG 的逻辑这个细节很容易被忽略。iPhone 默认拍照格式是 HEICWindows 电脑和不少安卓手机打不开所以源码把 HEIC 统一转成 JPEG。代价是转换耗时和画质损失如果你做的是 iPhone 到 iPhone 的迁移完全可以跳过转换保留 HEIC 反而体积更小。value(forKey: filename)这里是 KVC 取文件名虽然不优雅但在 2023 年依旧是最靠谱的方式因为PHAsset并没有公开的filename属性。4. 版本差异排查四个被系统“卡死”的常见问题这套源码能跑通是一回事在不同系统版本上不翻车是另一回事。下面四条是我拆这类备份项目时最有代表性的踩坑记录每一条都是“现象 → 原因 → 解决”的结构。4.1 Android 13 上相册读不出任何图片现象在 Android 13 真机上运行导出相册时进度条走完结果目录是空的但通讯录导出正常。原因Android 13API 33把READ_EXTERNAL_STORAGE拆分成了细粒度权限相册对应的是READ_MEDIA_IMAGES。旧源码的清单里只声明了READ_EXTERNAL_STORAGE运行时也只申请旧权限系统直接忽略不弹窗也不报错查询结果就是一个空集合。解决按系统版本分叉申请权限代码里要同时处理两套权限名val permission if (Build.VERSION.SDK_INT 33) { Manifest.permission.READ_MEDIA_IMAGES } else { Manifest.permission.READ_EXTERNAL_STORAGE }同时AndroidManifest.xml里要两个权限都声明低版本系统会自动忽略不认识的权限名。4.2 Android 10 之后 DATA 字段变成了“玄学”现象导出相册时偶发复制出的文件是 0 字节或者直接崩溃log 里能看到FileNotFoundException。原因Android 10 开始强制分区存储MediaStore.Images.Media.DATA这个字段虽然还在返回路径但 App 没有访问那个路径的权限即使query能查到路径File也打不开。很多旧代码直接拿DATA路径去FileInputStream在 Android 9 以下没问题到 Android 10 以上就翻车。解决不要用DATA字段改成先用_ID拼 content URI再走ContentResolver.openInputStream。这也是源码里采用的方式核心就一句话永远别拿路径直接拿流。4.3 iOS 用户拒绝过一次之后弹窗再也不出现现象用户第一次点“允许导通讯录”时误点了“不允许”再点导出按钮完全没有反应也没有任何提示。原因iOS 对通讯录和相册的权限授权是“一次决定终身生效”。用户拒绝后系统不会再次弹窗requestAccess回调直接返回.denied如果代码里没有对这一分支做处理就会出现“点击无响应”的假死状态。解决在PermissionHelper里检测到.denied状态时不再重复申请而是弹一个提示并引导用户去系统设置页手动打开权限switch status { case .denied, .restricted: DispatchQueue.main.async { guard let url URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } default: break }4.4 五千张原图导出把内存吃没了现象导出二千张以上照片时App 内存涨到 300M 以上进度条走到一半被杀掉闪退前还能看到内存警告日志。原因早期版本把所有图片的Data都塞进一个数组最后统一写盘。照片原图每张 3 到 5M两千张就是几个 G内存不爆才怪。解决改成逐张拷贝、即时释放的“流式导出”模式。每读一张原图数据立刻写文件写完把临时变量置空不要放在循环体外层的集合里。iOS 端用autoreleasepool包住每一轮的enumerateObjects回调确保中间对象及时释放。5. 本地运行把双端源码编译成可安装的 App源码拿到手最关心的就是怎么让它跑起来。两个工程需要分开编译但准备步骤有共同点。5.1 准备阶段两份工程的依赖与配置文件Android 端是在根目录的build.gradle.kts里声明依赖主要就两个库androidx.core:core-ktx提供系统 API 兼容androidx.appcompat提供基础组件。不需要额外引入第三方因为读取通讯录和相册全是系统 API。注意compileSdk要设成 33 或更高否则 Android 13 上的READ_MEDIA_IMAGES权限符号找不到编译直接报红。iOS 端只依赖系统框架Contacts和Photos在 Xcode 的工程设置里确认这两个框架已经添加到 Link Frameworks 阶段。Info.plist里必须配置两条用途描述缺失任何一条都会在申请权限时直接闪退keyNSPhotoLibraryUsageDescription/key string用于导出相册照片备份到本机/string keyNSContactsUsageDescription/key string用于导出通讯录联系人备份到本机/string这两条描述会原样显示在系统的权限弹窗上中文文案建议写清楚用途避免审核或用户误会。5.2 Android 端一次 gradle 命令出 Debug 包用 Android Studio 打开android/目录等 Gradle 同步完成。同步期间如果提示 SDK 版本问题在local.properties里加一行sdk.dir/你的SDK路径。之后执行打包命令./gradlew assembleDebug编译完成后APK 在app/build/outputs/apk/debug/app-debug.apk。直接安装到 API 33 的真机上用adb install或 Android Studio 的 Run 按钮都行。这里有个细节要注意如果你用模拟器系统默认带了一套固定相册和通讯录权限弹窗也少跑通流程没问题但导出量小验证不了内存问题。首次运行建议按这两个入口分别验证先点通讯录导出立刻回系统看通讯录里新增一条测试联系人再回来执行导出确认 VCF 里能搜到那条记录再跑相册导出确认月度目录的图片数量和系统相册一致。确认没问题后再去测大数据量场景。5.3 iOS 端签名与真机运行先用 Xcode 打开BackupApp.xcodeproj在 Signing 面板里把 Team 换成你自己的开发者账号Bundle Identifier 改成唯一的。真机运行前系统会要求你信任开发者证书进“设置 → 通用 → 设备管理”里点信任即可。跑起来之后iPhone 第一次点导出按钮会连续弹两个权限框先通讯录后相册。这里我建议你故意拒绝其中一个看看 App 的反应——按第 4 章的处理拒绝后应该能看到引导跳设置页的提示而不是无声无息。这是验证PermissionHelper是否生效最快的方式。模拟器上运行有一个坑模拟器的相册默认没有照片需要先拖几张图片到模拟器窗口里系统会自动存入相册。没有相册图片来源导出流程会返回 0 张容易被误判为 bug。5.4 功能验证用导出文件反查系统数据跑通后别急着收工要做一轮对照验证。通讯录方面读取生成的 VCF 文件统计BEGIN:VCARD的行数再和系统通讯录的总条数对比。Android 可以用adb pull把 App 私有目录下的 VCF 拉到电脑上iOS 用File共享或直接通过 Xcode 的容器面板导出。相册方面重点不是数总数而是看两点第一月度目录是否和时间戳匹配第二HEIC 是否被正确处理。如果你用的是 iPhone 拍的 HEIC 原图导出目录里应该多出对应的 JPEG 文件而不是一堆打不开的.HEIC。验证这一步做到位整个源码在你的环境里才算真正跑通了。6. 二次开发思路增量备份、加密导出与权限矩阵维护把基础流程跑通之后这个源码真正值钱的地方在于改造成自己的工具。我给出两个直接能落地的方向。第一个方向是增量备份。现在每次导出都是全量一千张照片时还无所谓一万张的时候每次等十分钟就不可接受了。实现思路是在本地保存一个上次导出的时间戳查询时只取晚于这个时间的资源。Android 的 MediaStore 查询条件可以加在selection参数里val selection ${MediaStore.Images.Media.DATE_ADDED} ? val selectionArgs arrayOf(lastExportTime.toString())iOS 端对应的做法是用PHFetchOptions的predicate过滤creationDate大于上次导出时间的资产。改完记得处理“用户删除过照片”的情况否则目标设备上会残留已删除照片的备份。第二个方向是给导出文件加密。备份了通讯录和相册等于把一个人的隐私打包放在本地一旦手机丢了这些文件是裸奔的。常见的做法是把导出的目录压成 ZIP再用 AES-GCM 加密val cipher Cipher.getInstance(AES/GCM/NoPadding) cipher.init(Cipher.ENCRYPT_MODE, secretKey)秘钥不要硬编码在源码里可以用系统 KeyStore 或用户口令派生。Android 上用 Android KeystoreiOS 用 Keychain这样就算备份文件被拷走没有当前设备的秘钥也打不开。最后说一个我维护这类仓库时养成的习惯权限矩阵。我每次改完导出逻辑第一件事不是看数据对不对而是拿着“首次安装 → 同意全部 → 拒绝部分 → 永久拒绝 → 重新安装”这张表逐条过一遍。有一次我调整了权限申请顺序把相册申请提前到启动页结果在 iOS 上审核被拒提示用途描述与调用时机不符。从那以后每次动权限相关代码我都强制走一遍双端权限矩阵核对。希望这些改造思路和踩坑记录能帮你少走几步弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?