☰
双端相册通讯录备份源码解析:Android与iOS实现
2026/10/23 13:58:33 网站建设 项目流程

简介:这是一套面向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 和 MediaStore,iOS 走 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;TYPE=CELL:13800138000 END:VCARD

FN字段是显示名,TEL;TYPE=CELL是手机号码标记。需要注意的是,源码这里做了两层清洗:空号码联系人直接跳过,重复的显示名加序号后缀,避免导入到目标设备时被识别成同一个人而合并。

相册导出的约定更直白:按拍摄月份建目录,形如2023-01/、2023-02/。这样迁移到新手机后,系统自动按年份和月份聚合,不会出现全部照片堆在一个文件夹里的“照片坟场”。文件命名用原始文件名加上哈希后缀,避免不同月份出现同名文件互相覆盖。这套约定虽然简单,但在真实换机场景里非常顶用,我拆过的不少付费换机工具,内部打包结构也就是这个思路。

3. 核心模块实现:从系统 API 到 VCF 与原图文件的完整链路

这一章是整个源码最值得抄作业的部分。我按 Android 和 iOS 分开讲,每一段都给出关键代码和参数说明。

3.1 Android 通讯录:一次 query 导出完整 VCF

Android 读取通讯录的核心是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;TYPE=CELL:${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;TYPE=CELL:\(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 默认拍照格式是 HEIC,Windows 电脑和不少安卓手机打不开,所以源码把 HEIC 统一转成 JPEG。代价是转换耗时和画质损失,如果你做的是 iPhone 到 iPhone 的迁移,完全可以跳过转换,保留 HEIC 反而体积更小。value(forKey: "filename")这里是 KVC 取文件名,虽然不优雅,但在 2023 年依旧是最靠谱的方式,因为PHAsset并没有公开的filename属性。

4. 版本差异排查:四个被系统“卡死”的常见问题

这套源码能跑通是一回事,在不同系统版本上不翻车是另一回事。下面四条是我拆这类备份项目时最有代表性的踩坑记录,每一条都是“现象 → 原因 → 解决”的结构。

4.1 Android 13 上相册读不出任何图片

现象:在 Android 13 真机上运行,导出相册时进度条走完,结果目录是空的,但通讯录导出正常。

原因:Android 13(API 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里必须配置两条用途描述,缺失任何一条都会在申请权限时直接闪退:

<key>NSPhotoLibraryUsageDescription</key> <string>用于导出相册照片备份到本机</string> <key>NSContactsUsageDescription</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 Keystore,iOS 用 Keychain,这样就算备份文件被拷走,没有当前设备的秘钥也打不开。

最后说一个我维护这类仓库时养成的习惯:权限矩阵。我每次改完导出逻辑,第一件事不是看数据对不对,而是拿着“首次安装 → 同意全部 → 拒绝部分 → 永久拒绝 → 重新安装”这张表逐条过一遍。有一次我调整了权限申请顺序,把相册申请提前到启动页,结果在 iOS 上审核被拒,提示用途描述与调用时机不符。从那以后,每次动权限相关代码我都强制走一遍双端权限矩阵核对。希望这些改造思路和踩坑记录,能帮你少走几步弯路。

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

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

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

立即咨询