:權限適配、數(shù)據讀取與統(tǒng)一序列化實現(xiàn)解析)
簡介這是一套2023年發(fā)布的雙端相冊與通訊錄獲取系統(tǒng)源碼覆蓋安卓與iOS端面向具備PHP和移動端開發(fā)基礎的工程師用于快速搭建可運行的相冊與通訊錄獲取管理平臺。源碼包共含2000個文件壓縮包大小約51.06MB主要文件構成包括1589個PHP文件承擔后端接口與業(yè)務邏輯74個CSS與57個JS渲染后臺管理界面510個PNG與41個JPG提供頁面UI素材同時附有APK與IPA安裝包可直接安裝體驗。部署說明隨包提供明確Apache 2.4.53、MySQL 5.6、PHP 5.6運行環(huán)境修改app/database.php完成數(shù)據庫連接、運行目錄指向public、開啟TP偽靜態(tài)并關閉跨站后即可搭建服務端后臺管理入口及默認賬號admin/123456均已標明安卓端APK還支持反編譯后替換服務器IP重新打包便于實際聯(lián)調。目前已有4618人學習下載適合需要完整掌握雙端獲取類系統(tǒng)部署流程或在此基礎上進行二次開發(fā)的工程師參考。1. 雙端相冊通訊錄系統(tǒng)它解決的不是讀取而是交付這套2023雙端完美獲取相冊通訊錄系統(tǒng)源碼解決的是移動端開發(fā)里最容易被低估的一塊如何把系統(tǒng)相冊和通訊錄高效、合規(guī)、可用地交到業(yè)務層手里。做過會員注冊、工單系統(tǒng)、私域運營工具的人都知道用戶愿意一鍵授權讀取本機通訊錄比手動輸入體驗好太多但雙端權限模型差異、Android 13 的細粒度媒體權限、iOS 相冊的“僅添加照片”模式隨便一個適配不到位就是閃退或者返回空數(shù)據。這套源碼的價值在于它把兩端讀取鏈路徹底打通適合接手后直接內嵌到自己 App 里的開發(fā)者也適合想跑通完整權限申請、數(shù)據遍歷、批量導出流程的移動端新手。它不新奇但確實能把雙端讀取的坑提前幫你踩完。2. 架構設計一套數(shù)據規(guī)范加兩個原生適配殼2.1 雙端共用的讀取鏈路模型整個系統(tǒng)的核心不是“怎么讀”而是“讀完給誰”。源碼里的調用鏈被抽象成了三層權限申請層、數(shù)據讀取層、結果序列化層。權限申請層負責按系統(tǒng)版本區(qū)分權限請求方式數(shù)據讀取層屏蔽了 Android 的 Cursor 與 iOS 的 CNContact/PHAsset 差異結果序列化層統(tǒng)一輸出成 JSON 結構業(yè)務層只需要處理一個 List 類型。業(yè)務層 → 統(tǒng)一接口getContacts() / getAlbums() → 權限申請器PermissionRequestor → 平臺讀取器AndroidContactsContract / MediaStoreiOSContacts / PhotoKit → 結果封裝JSON / Model這套設計有一個明顯好處新增一個業(yè)務場景比如只需要讀取號碼不需要姓名時不用動讀取器只改結果封裝層就行。在二次開發(fā)里你基本只需要盯住序列化層和權限申請層大部分坑也集中在這兩層。2.2 工程目錄結構速覽源碼按雙端拆成兩個獨立工程但共享一份接口文檔和數(shù)據模型約定。Android 側采用 Kotlin MVVMiOS 側采用 Swift UIKit沒有引入跨平臺框架主要原因是相冊通訊錄這類系統(tǒng)級能力原生 API 最穩(wěn)踩坑最少。├── android/ │ ├── app/src/main/java/com/xxx/ │ │ ├── permission/ # 權限申請與回調 │ │ ├── reader/ # 通訊錄、相冊讀取器 │ │ ├── model/ # 統(tǒng)一數(shù)據模型 │ │ └── serialization/ # JSON 序列化與壓縮 │ └── app/src/main/AndroidManifest.xml ├── ios/ │ ├── PermissionManager/ # 權限狀態(tài)機 │ ├── Readers/ # ContactReader / PhotoReader │ ├── Models/ # 數(shù)據模型 │ └── Utils/ # 圖片壓縮、批量導出 └── docs/ ├── 雙端接口約定.md └── 權限適配清單.md2.3 統(tǒng)一返回的數(shù)據模型無論哪一端最終返回給業(yè)務層的數(shù)據字段是一致的。通訊錄字段為 name、phone、email、organization、note相冊字段為 assetId、uri、width、height、duration、dateTaken、size。這么設計的好處是前端和接口對接方只看一套字段規(guī)范不用關心數(shù)據來源是 Android 還是 iOS。源碼里用了一個 Contract 常量類來維護字段名避免硬編碼字符串散落各處。{ code: 0, data: [ { name: 張三, phone: 138****1234, email: , organization: 某公司, note: } ], message: success }這段 JSON 是所有讀取結果的最終出口。code 為 0 代表成功非 0 是權限拒絕或讀取異常編號。業(yè)務層拿到這個結構后做列表展示或者批量上傳都很省事。3. 通訊錄模塊拆解從權限申請到批量導出的完整鏈路3.1 Android 端通訊錄讀取與權限適配Android 端讀取通訊錄的核心類 ContactsContract。源碼里在 MainActivity 啟動時先走權限流程然后執(zhí)行查詢。凡是在 Android 6.0 以上運行的系統(tǒng)都需要動態(tài)申請 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) } } }這段代碼做了兩件事檢查當前系統(tǒng)是否大于等于 Android 6.0再檢查權限是否已授予。低于 6.0 的系統(tǒng)在安裝時授權不需要動態(tài)申請所以直接回調成功?;卣{邏輯需要在 Activity 的 onRequestPermissionsResult 里補上源碼里用了一個接口回調的方式把結果拋到上層。拿到權限之后真正的查詢邏輯在 ContactReader 里。它通過 ContentResolver 查詢通訊錄的 Phone.CONTENT_URI遍歷 Cursor 取出字段。這里有個常見的性能坑通訊錄一旦上千條主線程直接查詢會卡界面源碼里的做法是放到協(xié)程的 IO 線程執(zhí)行。// 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 }參數(shù)說明projection 數(shù)組決定查詢哪些列查詢的字段越多耗時越長如果業(yè)務只需要號碼建議只保留 NUMBER 和 DISPLAY_NAME。cursor.use{} 是 Kotlin 擴展函數(shù)能保證 Cursor 最終被關閉避免內存泄漏。整個函數(shù)被 withContext(Dispatchers.IO) 包裹讀取大量聯(lián)系人時不會阻塞 UI 線程。3.2 iOS 端通訊錄讀取與權限分級iOS 端使用的是 Contacts 框架。和 Android 不同iOS 的通訊錄權限只區(qū)分“允許”和“拒絕”沒有 Android 那套一次性授權的概念一旦用戶拒絕除非去系統(tǒng)設置里手動打開否則 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 } }邏輯說明先檢查授權狀態(tài)notDetermined 時才彈窗請求authorized 直接讀取其余狀態(tài)一律返回空數(shù)組。這套狀態(tài)機保證了 App 不會在權限被拒后反復彈窗也更符合 App Store 審核規(guī)范。fetchFromStore 里只請求了必要字段沒有把所有屬性全部拉下來iOS 端這個懶加載性能優(yōu)化很關鍵聯(lián)系人幾千條時差異明顯。3.3 批量導出與去重策略通訊錄最容易出現(xiàn)臟數(shù)據——同一個聯(lián)系人有多個號碼或者重復錄入兩遍。源碼里在導出前做了一次規(guī)則去重以“姓名 第一個手機號”作為唯一鍵后續(xù)重復數(shù)據直接丟棄。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效果在絕大多數(shù)場景下夠用。如果遇到同名同號但不同公司的情況可以再把 organization 拼進去但那樣做會把更多樣本判定為不同聯(lián)系人。實際使用中建議保持簡單畢竟通訊錄本身就是用戶自己維護的數(shù)據過多干預反而會引發(fā)投訴。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 之后還在用舊權限系統(tǒng)會直接不授予。源碼里做了一個兼容寫法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 的設備會自動忽略舊權限聲明只認新權限。這種寫法比代碼里動態(tài)判斷后再申請更干凈系統(tǒng)層面就替你做了一次分流。讀取相冊本身用的是 MediaStore。源碼里做的是“增量讀取”也就是每次只取新增的圖片這個能力在系統(tǒng)相冊動輒萬張圖片的情況下特別重要。判斷新增的字段是 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下次用最后一次讀取結果里的最大值傳入就能做到持續(xù)性增量同步。朋友圈備份、云相冊同步這類功能都可以基于這一段改造。4.2 iOS 端 PHPicker 與 PhotoKit 選擇iOS 讀相冊有兩種思路一種是直接用 PHPickerViewController這是 iOS 14 之后蘋果推薦的系統(tǒng)級選擇器不需要申請相冊權限隱私保護最好另一種是使用 PhotoKit 的 PHAsset 枚舉能拿到全量資源但必須觸發(fā)相冊權限彈窗。源碼里把兩種方式都實現(xiàn)了默認走 PHPicker適合業(yè)務上只需要用戶選圖的場景也暴露了 PhotoKit 的全量讀取接口供后臺同步類功能使用。// PhotoPickerManager.swift import PhotosUI class PhotoPickerManager: NSObject, PHPickerViewControllerDelegate { func presentPicker(from viewController: UIViewController) { var config PHPickerConfiguration() config.filter .images config.selectionLimit 0 // 0 表示不限數(shù)量 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 無法偷偷讀取整個相冊這在隱私合規(guī)審查里是加分項。selectionLimit 為 0 代表多選如果只想單選就改成 1。loadObject 是異步加載回調可能不在主線程做 UI 更新時記得切回主隊列。4.3 圖片壓縮與分頁加載相冊數(shù)據量大一次性把原圖路徑全部返回給業(yè)務層內存直接爆掉。源碼里在讀取階段不做壓縮只返回 uri 和尺寸信息真正要顯示或上傳的時候才壓縮。壓縮參數(shù)據寫在個 Constants 類里默認限制最長邊為 1280 像素、質量 85%這段邏輯在兩端各實現(xiàn)了一遍但參數(shù)完全一致。val MAX_EDGE 1280 val COMPRESS_QUALITY 85這段常量看起來不起眼實際上決定了上傳帶寬和縮略圖展示速度。如果想降低傳輸成本把 MAX_EDGE 改成 800圖片體積能減少一半以上如果做原圖備份就改成 4096 或者保存原始 DATA 路徑。壓縮算法在 Android 用 BitmapFactory MatrixiOS 用 UIGraphicsImageRenderer源碼里都有現(xiàn)成實現(xiàn)直接復用即可。5. 雙端適配避坑權限版本差異與數(shù)據查詢的六個常見問題5.1 小米和三星手機上通訊錄權限返回已授予但查詢結果一直為空現(xiàn)象同一套代碼在模擬器上能查到聯(lián)系人在部分國產手機上永遠返回空列表權限明明已經授予了。原因部分廠商系統(tǒng)里通訊錄權限拆成了“讀取聯(lián)系人”和“讀取通話記錄”兩個開關ContactsContract 查詢依賴的核心權限是 READ_CONTACTS但個別 ROM 會默認關閉“讀取設備通話記錄”的子權限導致 query 拿到了一個空 Cursor。解決在權限申請成功后主動做一次探針查詢例如查通訊錄的聯(lián)系人總數(shù)如果等于 0 但設置里授權是開著的就跳轉系統(tǒng)設置頁提示用戶檢查權限細分項。源碼里的 PermissionProbe 類專門干這件事血淚經驗是別完全信任 checkSelfPermission 的返回值。5.2 Android 13 上申請了 READ_MEDIA_IMAGES還在報 SecurityException現(xiàn)象targetSdk 已升級到 33Manifest 里也加了新權限運行時申請也提示成功但訪問 MediaStore 仍然拋權限異常。原因沒有給舊權限加 maxSdkVersion32。系統(tǒng)在高版本上看到 Manifest 同時存在新舊兩個權限聲明會采用更嚴格的解析策略此時只授予了新權限但代碼里某處還在用舊權限常量做校驗。解決按 4.1 節(jié)的寫法在舊權限聲明上強制加 maxSdkVersion32。檢查全項目是否還有 READ_EXTERNAL_STORAGE 的引用尤其是第三方 SDK 的依賴里如果聲明了這個權限也會干擾新權限授予。5.3 iOS 通訊錄權限被用戶拒絕一次后再也彈不出授權框現(xiàn)象第一次彈窗用戶點了“不允許”后續(xù) App 里怎么調用 requestAccess 都沒有彈窗直接返回 denied。原因iOS 的設計就是這樣權限狀態(tài)一旦變成 denied系統(tǒng)不會再向用戶彈出授權界面只能由用戶自己去“設置 隱私”里手動開啟。解決在回調中檢測到 .denied 狀態(tài)時跳轉 UIApplication.openSettingsURLString 打開系統(tǒng)設置頁并在界面文案里說明為什么要用通訊錄。源碼里封裝了一個 goToSettings() 輔助方法。注意不要自己彈一個假的授權彈窗審核容易被拒。5.4 Cursor 未關閉導致的內存泄漏與重復讀取現(xiàn)象通訊錄反復讀取后 App 內存持續(xù)升高甚至出現(xiàn) native heap 溢出崩潰。原因Cursor 是一個持有底層數(shù)據庫資源的對象不用時必須 close。很多新手只寫了 query 沒寫 close系統(tǒng) GC 又不會立刻回收底層資源導致泄漏。解決統(tǒng)一用 Kotlin 的 use 擴展函數(shù)包裹 Cursor 操作確保查詢結束自動關閉。源碼里所有 query 都遵循這個模式C 端在線程切換上再注意一點就能徹底解決問題。5.5 iOS 模擬器上通訊錄讀取正常真機上偶發(fā) crash現(xiàn)象同一套代碼模擬器流暢跑完真機上偶發(fā)直接崩潰點開相冊圖片時 CPU 暴增。原因真機的相冊圖片分辨率遠高于模擬器加載原圖到 UIImage 會一次性占用幾百 MB 內存觸發(fā) Jetsam 機制直接殺掉進程。模擬器共享電腦內存不容易爆掩蓋了問題。解決使用增量加載從 PHAsset 取圖時用 PHImageManager 的 requestImage 并指定 targetSize不要直接請求原始尺寸。源碼里把 targetSize 設為列表縮略圖需要的尺寸大約 600x600這樣既清晰又不會撐爆內存。5.6 通訊錄號碼格式不統(tǒng)一有的帶 86 前綴有的帶空格和橫杠現(xiàn)象讀取回來的 phone 字段五花八門用戶在列表搜索時明明輸入對了一部分卻匹配不上。原因系統(tǒng)通訊錄本身沒有強制號碼格式化規(guī)則用戶在錄入時手輸了什么就存什么Android 返回的甚至可能是 SIP 協(xié)議開頭的內容。解決在序列化層統(tǒng)一做一次號碼清洗去掉空格、橫杠、括號把 86 替換為空保留純數(shù)字和分機 X 后綴。這個工作放在整條鏈路的最后一步業(yè)務層拿到的就是相對干凈的號碼。源碼里 CleanPhoneUtil 這段工具函數(shù)不復雜但價值很高。6. 二次開發(fā)把讀取能力改造成自己的通訊錄同步服務如果你拿這套源碼不是為了學習而是直接要落地到自己的產品里我的建議是先砍掉一半功能再接入。絕大多數(shù)業(yè)務場景只需要通訊錄的手機號字段不需要 email 和 organization相冊只需要縮略圖和上傳回調不需要原始數(shù)據流。第一步就是精簡數(shù)據模型把不需要的字段從 projection 里刪掉這不僅讓讀取更快也讓隱私合規(guī)的說明文檔更好寫。實際改造時我會按三步走。第一步換數(shù)據出口把序列化層從本地 List 改成網絡上報。原代碼已經把結果封裝成 JSON你只需要寫一個 Retrofit 或 Alamofire 的上報類把 JSON 體 POST 到你的服務端。注意加一個本地緩存隊列弱網環(huán)境下上報失敗后自動重試避免用戶授權了數(shù)據卻傳不上去。fun submitContacts(contacts: ListContactModel) { val params JSONObject().apply { put(app_id, APP_ID) put(data, JSONArray(contacts.map { it.toJsonObject() })) } // 通過 OKHttp / URLSession 發(fā)送到服務端失敗則寫入本地隊列 }第二步是加一個同步狀態(tài)位。讀取成功后給每個聯(lián)系人記錄一個 hash 值下次啟動時先比對 hash 再決定是否上報。這一步在通訊錄類應用里極其重要否則每次冷啟動都上傳全量通訊錄服務端壓力大用戶流量也吃不消。第三步是驗證。雙端項目最怕改了 Android 忘了 iOS我的習慣是維護一份參數(shù)對照表把兩端用到的權限常量、查詢字段、壓縮參數(shù)列在一張表里每次改完一邊就同步核對另一邊。這套源碼的 Contract 類已經替你做了一大半遵守它的約定就不會出現(xiàn)兩端行為不一致的問題。這套源碼適合當做一個基礎設施引擎來使用不要試圖理解每一行系統(tǒng) API 的原理先把業(yè)務跑起來。等出現(xiàn)性能和兼容性問題再回頭讀對應模塊效率最高。從那以后我每次接手雙端項目都會強制先跑一遍兩端的權限場景矩陣把授予、拒絕、撤銷、僅使用期間允許這幾種狀態(tài)全部過一遍再確認數(shù)據出口。希望幫到你。本文還有配套的精品資源點擊獲取