双端通讯录源码拆解:iOS与安卓通讯录、相册、短信、定位模块实现
简介:这是一套面向移动端开发学习者的双端通讯录类项目源码,覆盖iOS与安卓两个平台,适合具备一定前端与PHP基础、希望研究移动应用数据采集与后台管理实现方式的开发者参考。项目采用HBuilder X进行APP打包,后端基于ThinkPHP框架搭建,包含完整的后台管理入口与数据库配置说明,可帮助读者理解从客户端到服务端的数据交互链路。压缩包共约2000个文件,整体298.59MB,其中548个php文件构成后端业务逻辑,475个png与148个css、114个js负责界面与交互资源,另有186个md文档、88个xml配置及apk、ipa安装包等,目录层次分明,便于按模块定位学习。目前已有545人学习关注。资源涵盖通讯录、相册、短信、定位及已安装应用信息等模块的开源实现,代码未加密,读者可据此研究移动端权限调用、接口设计与后台数据管理的具体写法,并借助说明文档完成本地环境部署与调试,适合作为课程设计或技术预研的参考素材。
1. 双端通讯录源码拆解:从通讯录到相册短信,一套代码怎么跑通 iOS 和安卓
通讯录、相册、短信、手机号定位——这四个词摆在一起,做过移动端的人大概能猜到,这不是一个简单的单点功能 Demo,而是一套完整的双端应用源码。iOS 和安卓各一套工程,功能模块覆盖了通讯录管理、相册浏览、短信读取和基于手机号的定位能力。拿到这份源码的开发者,通常有两类诉求:一类是想快速跑起来看看效果,验证功能是否完整;另一类是想在现有基础上做二次开发,比如换 UI、接自己的后端、或者把某个模块单独抽出来用。这套源码的价值在于,它把双端工程结构和核心功能模块都摆出来了,省去了从零搭框架的时间。接下来我会按「项目结构 → 环境搭建 → 核心模块 → 避坑 → 二次开发」的顺序,把这份源码拆开讲清楚。
2. 工程结构与环境搭建:双端目录怎么分、依赖怎么装
2.1 双端工程目录结构
拿到压缩包解压后,常见的目录组织方式是按平台分目录,iOS 和安卓各占一个顶层文件夹,共享的资源文件单独放。我一般会先扫一遍根目录,确认工程入口在哪。
# 解压后典型目录结构(以实际源码为准) AddressBookSource/ ├── iOS/ # iOS 端工程目录 │ ├── AddressBook.xcodeproj │ ├── AddressBook/ # 主工程源码 │ │ ├── Controllers/ # 视图控制器 │ │ ├── Models/ # 数据模型 │ │ ├── Views/ # 自定义视图 │ │ └── Utils/ # 工具类 │ └── Podfile # CocoaPods 依赖描述 ├── Android/ # 安卓端工程目录 │ ├── app/ │ │ ├── src/main/java/ # Java/Kotlin 源码 │ │ ├── src/main/res/ # 资源文件 │ │ └── build.gradle # 模块构建脚本 │ ├── build.gradle # 项目级构建脚本 │ └── settings.gradle └── README.md # 说明文件这个结构是移动端双端项目的常见做法。iOS 端用 Xcode 工程组织,安卓端用 Gradle 构建。两边的业务逻辑各自独立,但功能模块的划分是对齐的——通讯录、相册、短信、定位各对应一组控制器和工具类。这样设计的好处是,如果你只关心某一个平台,可以直接打开对应目录,不用管另一边。
2.2 iOS 端环境准备
iOS 端需要 macOS 系统,安装 Xcode,版本建议 14 以上。如果工程用了 CocoaPods 管理依赖,先装好 CocoaPods,然后在 iOS 目录下执行安装命令。
# 进入 iOS 工程目录 cd AddressBookSource/iOS # 安装 CocoaPods 依赖(如果存在 Podfile) pod install # 安装完成后用 .xcworkspace 打开,而不是 .xcodeproj open AddressBook.xcworkspace这里有个细节:如果源码里没有 Podfile,说明依赖可能是手动拖入的 framework,或者用了 Swift Package Manager。打开工程后先看 Build Phases 里的 Link Binary With Libraries,确认依赖是否完整。常见依赖包括通讯录框架 Contacts、相册框架 Photos、短信框架 MessageUI,这些都是系统框架,不需要额外安装。
2.3 安卓端环境准备
安卓端需要 Android Studio,JDK 版本看 build.gradle 里的配置。打开 Android 目录,等待 Gradle 同步完成。
# 进入安卓工程目录 cd AddressBookSource/Android # 查看 gradle 版本配置 cat gradle/wrapper/gradle-wrapper.properties # 命令行构建(可选) ./gradlew assembleDebugGradle 同步时最容易卡在依赖下载上。如果公司网络有限制,可以在项目级 build.gradle 里把仓库地址换成国内镜像。同步完成后,连接真机或启动模拟器,直接点 Run 就能装上去。注意安卓 6.0 以上需要动态申请权限,通讯录、相册、短信、定位这四个权限都得在运行时请求,否则功能会直接崩。
2.4 权限配置清单
双端都需要在配置文件里声明权限,这是跑通功能的前提。
| 平台 | 配置文件 | 需要声明的权限 |
|---|---|---|
| iOS | Info.plist | NSContactsUsageDescription、NSPhotoLibraryUsageDescription、NSLocationWhenInUseUsageDescription |
| 安卓 | AndroidManifest.xml | READ_CONTACTS、READ_EXTERNAL_STORAGE、READ_SMS、ACCESS_FINE_LOCATION |
iOS 的权限描述文案必须写清楚用途,否则审核会被拒。安卓这边,READ_SMS 属于敏感权限,Google Play 对它有额外限制,如果只是自己用或者企业内部使用,问题不大。
3. 核心模块拆解:通讯录、相册、短信、定位各怎么实现
3.1 通讯录模块:读取、搜索与展示
通讯录模块的核心是读取系统联系人数据,然后做本地搜索和列表展示。iOS 用 Contacts 框架,安卓用 ContentResolver 查询 ContactsContract。
先看 iOS 端的读取逻辑:
// iOS 通讯录读取核心代码 import Contacts func fetchContacts() { let store = CNContactStore() // 请求授权 store.requestAccess(for: .contacts) { granted, error in guard granted else { return } // 指定要读取的字段 let keys = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor] let request = CNContactFetchRequest(keysToFetch: keys) do { try store.enumerateContacts(with: request) { contact, stop in // 组装数据模型 let name = "\(contact.familyName)\(contact.givenName)" let phones = contact.phoneNumbers.map { $0.value.stringValue } // 存入本地数组,刷新 UI self.contactList.append(Contact(name: name, phones: phones)) } DispatchQueue.main.async { self.tableView.reloadData() } } catch { print("读取失败: \(error)") } } }这段代码的关键点:CNContactFetchRequest的keysToFetch决定了你能拿到哪些字段,不指定就只返回姓名。enumerateContacts是同步遍历,数据量大时建议放到后台线程。读取完成后必须回主线程刷新 UI,否则列表不更新。
安卓端的查询逻辑:
// 安卓通讯录查询核心代码 private void loadContacts() { ContentResolver resolver = getContentResolver(); Cursor cursor = resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); if (cursor != null) { while (cursor.moveToNext()) { String name = cursor.getString(cursor.getColumnIndex( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); String number = cursor.getString(cursor.getColumnIndex( ContactsContract.CommonDataKinds.Phone.NUMBER)); contactList.add(new Contact(name, number)); } cursor.close(); } adapter.notifyDataSetChanged(); }安卓这边用ContentResolver查询Phone.CONTENT_URI,按姓名排序。注意 cursor 用完必须 close,否则内存泄漏。搜索功能一般是在本地列表上做过滤,不重新查数据库,这样响应更快。
3.2 相册模块:缩略图加载与全屏预览
相册模块要解决两个问题:一是快速加载缩略图,二是点击后全屏预览。iOS 用 Photos 框架,安卓用 MediaStore。
iOS 端获取相册资源:
// iOS 相册资源获取 import Photos func fetchAssets() { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] let assets = PHAsset.fetchAssets(with: .image, options: options) assets.enumerateObjects { asset, _, _ in // 用 PHImageManager 请求缩略图 let manager = PHImageManager.default() let size = CGSize(width: 200, height: 200) manager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in self.thumbnails.append(image) } } }PHFetchOptions的排序规则决定了相册的展示顺序,一般按创建时间倒序。requestImage是异步的,targetSize 设小一点可以加快缩略图加载速度。全屏预览时再请求原图,避免一次性加载大图导致内存暴涨。
安卓端用 MediaStore 查询图片:
// 安卓相册查询 String[] projection = {MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA}; Cursor cursor = getContentResolver().query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, MediaStore.Images.Media.DATE_ADDED + " DESC" ); while (cursor.moveToNext()) { String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)); imageList.add(path); } cursor.close();安卓 10 以后分区存储生效,DATA字段可能拿不到真实路径,需要用ContentResolver.openInputStream配合 Uri 来读取。这是很多老代码在新系统上翻车的地方。
3.3 短信模块:读取会话与消息列表
短信模块在 iOS 上限制比较多,系统不提供公开 API 读取短信内容,所以 iOS 端的短信功能通常是发送短信(MessageUI)或者展示本地模拟数据。安卓端可以通过 ContentResolver 读取短信数据库。
安卓读取短信:
// 安卓短信读取 Uri uri = Uri.parse("content://sms/"); Cursor cursor = getContentResolver().query(uri, new String[]{"_id", "address", "body", "date"}, null, null, "date DESC"); while (cursor.moveToNext()) { String address = cursor.getString(1); // 发送方号码 String body = cursor.getString(2); // 短信内容 long date = cursor.getLong(3); // 时间戳 smsList.add(new Sms(address, body, date)); } cursor.close();content://sms/是短信数据库的 URI,查询时需要 READ_SMS 权限。注意这个 URI 在不同安卓版本上行为可能不一致,部分厂商 ROM 会限制访问。如果只是做展示,建议加个 try-catch,拿不到数据时给个空状态提示。
iOS 端发送短信:
// iOS 发送短信 import MessageUI func sendSMS(to number: String, body: String) { guard MFMessageComposeViewController.canSendText() else { return } let controller = MFMessageComposeViewController() controller.recipients = [number] controller.body = body controller.messageComposeDelegate = self present(controller, animated: true) }MFMessageComposeViewController只能调起系统发送界面,不能静默发送,这是 iOS 的安全限制。
3.4 手机号定位模块:基站与网络定位的调用方式
手机号定位这个功能需要说清楚:单纯靠手机号本身是拿不到实时位置的,必须结合设备端的定位能力。这套源码里的定位模块,通常是调用系统定位 API 获取经纬度,然后做逆地理编码显示城市和地址。
iOS 端定位:
// iOS 定位核心 import CoreLocation class LocationManager: NSObject, CLLocationManagerDelegate { let manager = CLLocationManager() func start() { manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyHundredMeters manager.requestWhenInUseAuthorization() manager.startUpdatingLocation() } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.last else { return } // 逆地理编码 CLGeocoder().reverseGeocodeLocation(location) { placemarks, _ in let city = placemarks?.first?.locality ?? "未知" print("当前城市: \(city)") } } }desiredAccuracy设成百米级就够了,精度越高越耗电。逆地理编码是异步的,返回结果里包含国家、省份、城市、街道等信息。
安卓端定位:
// 安卓定位 LocationManager lm = (LocationManager) getSystemService(Context.LOCATION_SERVICE); if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000, 10, locationListener); }安卓这边可以用 GPS_PROVIDER 或 NETWORK_PROVIDER,前者精度高但耗电,后者快但精度低。实际项目里一般两个都用,根据场景切换。
4. 避坑与常见问题:权限、兼容性、数据读取的翻车现场
4.1 权限申请后功能仍然不可用
现象:代码里明明申请了权限,用户也点了允许,但读取通讯录还是返回空。
原因:iOS 端requestAccess是异步的,如果在回调外直接调用读取逻辑,此时授权还没完成。安卓端在 Android 6.0 以上,权限申请和实际使用之间如果隔了页面跳转,可能被系统回收。
解决:iOS 端把所有读取逻辑放在授权回调的granted == true分支里。安卓端在onRequestPermissionsResult里确认授权后再执行查询,不要依赖checkSelfPermission的瞬时结果。
4.2 安卓 10 以上相册路径拿不到
现象:MediaStore.Images.Media.DATA返回 null 或者无效路径。
原因:安卓 10 引入分区存储,应用不能直接访问外部存储的绝对路径。
解决:改用ContentResolver.openInputStream(uri)读取图片流,或者申请MANAGE_EXTERNAL_STORAGE权限(但上架审核会麻烦)。推荐用 Glide 或 Coil 直接加载 Uri,它们内部已经处理了分区存储的兼容问题。
4.3 iOS 短信功能被审核拒绝
现象:App 提交审核时被拒,理由是短信权限使用不当。
原因:iOS 不允许读取短信内容,只能通过MFMessageComposeViewController发送。如果代码里试图访问短信数据库,会被直接拒。
解决:iOS 端的短信模块只保留发送功能,展示功能用本地数据或后端接口。如果产品需求必须展示短信,考虑改成展示通知或者让用户手动导入。
4.4 定位权限弹窗不出现
现象:调用requestWhenInUseAuthorization后没有任何反应。
原因:Info.plist 里缺少NSLocationWhenInUseUsageDescription字段,或者字段值为空。
解决:在 Info.plist 里补上描述文案,内容要具体说明用途,比如「用于展示您当前所在城市」。安卓端同理,AndroidManifest.xml 里必须声明ACCESS_FINE_LOCATION,否则运行时申请会被直接拒绝。
4.5 双端数据模型不一致导致联调困难
现象:iOS 和安卓返回的联系人字段名不同,后端接口对不上。
原因:两端各自定义 Model,没有统一字段命名规范。
解决:在项目初期就约定好数据交换格式,比如都用{name, phone, avatar}这样的结构。可以在共享目录放一份 JSON Schema,两端按这个来序列化。
5. 二次开发与改造思路:换 UI、接后端、抽模块
5.1 替换 UI 层的切入点
这套源码的 UI 层和业务逻辑基本是分离的,iOS 端 Controller 负责布局,Model 负责数据,View 负责展示。如果你想换一套 UI,只需要改 View 和 Controller 的布局代码,Model 和工具类不用动。安卓端同理,把 res/layout 下的 XML 换掉,Adapter 里的绑定逻辑调整一下就行。
我一般会先找到列表页的 Controller 或 Activity,把它的布局文件替换成自己的设计,然后跑一遍看数据能不能正常渲染。如果数据能出来但样式不对,说明只改布局就够了;如果数据出不来,那可能是 Adapter 或 DataSource 的绑定逻辑需要调整。
5.2 接入自有后端接口
源码默认读的是本地数据,要接后端的话,需要把数据源从系统 API 换成网络请求。iOS 端可以用 URLSession 或 Alamofire,安卓端用 Retrofit 或 OkHttp。改造步骤:
// iOS 端替换数据源示例 func loadContactsFromServer() { guard let url = URL(string: "https://your-api.com/contacts") else { return } URLSession.shared.dataTask(with: url) { data, _, error in guard let data = data, error == nil else { return } do { let contacts = try JSONDecoder().decode([Contact].self, from: data) DispatchQueue.main.async { self.contactList = contacts self.tableView.reloadData() } } catch { print("解析失败: \(error)") } }.resume() }关键点是数据模型要跟后端字段对齐,用CodingKeys做映射。安卓端用 Gson 或 Moshi 解析,Retrofit 的接口定义里把返回类型写成List<Contact>。
5.3 把定位模块抽成独立组件
如果你只想用定位功能,可以把 LocationManager 相关的代码单独抽出来。iOS 端把 CoreLocation 的封装类复制到新工程,安卓端把定位工具类连同权限申请逻辑一起搬过去。注意抽离时要保留权限声明,否则新工程里功能会失效。
5.4 改造后的验证方法
改完代码后,我习惯按这个顺序验证:先跑通编译,确认没有语法错误;然后装到真机上,逐个功能点一遍;最后看日志有没有异常输出。通讯录和相册这种依赖系统数据的模块,最好在真机上测,模拟器的数据往往不完整。定位功能在室内可能拿不到 GPS 信号,需要到窗边或室外测试。
从那以后我每次拿到双端源码,都强制先跑一遍权限申请流程,确认四个权限都能正常弹出再往下看业务代码。希望帮到你。
本文还有配套的精品资源,点击获取