
Contact Picker ของ Android 17 ใน Flutter: ถึงเวลาแทนที่ READ_CONTACTS
- David Serrano Canales
- 19 ตุลาคม G 2026
สารบัญ
Android 17 มาพร้อมการเปลี่ยนแปลงสำคัญเรื่องการอ่านรายชื่อผู้ติดต่อบนเครื่อง ตอนนี้เราเรียก ตัวเลือกของระบบ ขึ้นมาเพื่อเลือกผู้ติดต่อและนำเข้าสู่แอปของเราได้ โดยไม่ต้องประกาศสิทธิ์การอ่าน READ_CONTACTS ใน manifest อีกต่อไป
ด้วยเหตุนี้ Google จึงประกาศข้อจำกัดใหม่ สิทธิ์ READ_CONTACTS ถูกสงวนไว้สำหรับแอปที่ไม่สามารถทำงานได้ด้วยตัวเลือกของระบบเพียงอย่างเดียว นั่นแปลว่า ถ้าแอปของคุณนำเข้าผู้ติดต่อแบบง่าย ๆ คุณจะต้องใช้ตัวเลือกนี้ แต่ถ้าสิทธิ์นั้นจำเป็นจริง ๆ ต่อการทำงานของแอป คุณจะต้องส่งคำชี้แจงใน Play Console อธิบายว่าทำไมตัวเลือกของระบบถึงไม่เพียงพอ นโยบายนี้มีผลวันที่ 27 มกราคม 2027
อย่างไรก็ดี เรื่องนี้ใช้กับแอปที่ตั้งเป้าไปที่ targetSdk 37 ขึ้นไป ถ้าแอปของคุณยังตั้งเป้าที่เวอร์ชันเก่ากว่านั้น นโยบายยังไม่กระทบคุณในตอนนี้ แต่จะตามมาทันทีที่คุณขยับ target ขึ้น
ในบทความนี้ผมจะพยายามให้แนวทางกับคุณว่าเรื่องนี้หมายถึงอะไร จะกระทบคุณอย่างไร และคุณจำเป็นต้องใช้สิทธิ์นั้นต่อไปจริงไหม หรือแค่ตัวเลือกของระบบก็พอแล้ว จากนั้นผมจะยกตัวอย่างโค้ดให้ดูว่าผมเองใช้ตัวเลือกของระบบบน Android 17+ ในแอปของตัวเองอย่างไร
Info
📽 มีเวอร์ชันวิดีโอบน YouTube
นโยบายผู้ติดต่อใหม่ของ Google Play กระทบแอปของคุณหรือเปล่า?
แอปส่วนใหญ่ที่เข้าถึงผู้ติดต่อจะยังทำงานได้ดีเหมือนเดิมด้วยตัวเลือกของระบบ โดยไม่ต้องประกาศสิทธิ์ READ_CONTACTS เลย
ถ้าแอปของคุณเข้าถึงผู้ติดต่อเป็นครั้งคราว เช่น เพื่อนำเข้ามาไว้ในแอป หรือเพื่อดึงข้อมูลบางอย่างของผู้ติดต่อคนหนึ่งในช่วงเวลาหนึ่ง ตัวเลือกของระบบก็เกินพอแล้ว
ความต่างอยู่ที่ขอบเขต ด้วยสิทธิ์นั้นคุณอ่านสมุดรายชื่อทั้งเล่ม ส่วนตัวเลือกของระบบจะให้เฉพาะผู้ติดต่อที่ผู้ใช้เลือกเท่านั้น แถมคุณยังขอเฉพาะฟิลด์ที่สนใจได้ด้วย การเข้าถึงก็เป็นแบบชั่วคราว หมดอายุทันทีที่คุณอ่านรายการที่เลือกเสร็จ
ลองดูแอปของผมเป็นตัวอย่าง ติดตามหนี้ เป็นแอปที่คุณสร้างผู้ติดต่อขึ้นมา แล้วผูกหนี้และการชำระเงินเข้ากับคนเหล่านั้น ผู้ติดต่อสร้างเองในแอปก็ได้ หรือจะเลือกนำเข้าจากสมุดรายชื่อของเครื่องเพื่อความสะดวกก็ได้ use case ของผมคือการทำงานครั้งเดียวจบพอดี นั่นคือ «ดึงผู้ติดต่อจากสมุดรายชื่อของเครื่องเข้ามาในแอป»
ตัวเลือกของระบบเหมาะกับผมมาก ผมไม่ต้องการการเข้าถึงสมุดรายชื่อแบบซ้ำ ๆ ไม่รู้จบ ผมต้องการแค่การเข้าถึงครั้งเดียวในช่วงเวลาหนึ่ง เพื่อดึงผู้ติดต่อเข้ามาและเก็บไว้ในแอปเท่านั้น
⬆️ ตัวเลือกที่คุณเห็นในวิดีโอนี้คือตัวเลือกเนทีฟตัวใหม่ของ Android 17
กรณีที่ยังมีเหตุผลรองรับสิทธิ์นั้นอยู่ คือกรณีที่ต้องใช้สมุดรายชื่อทั้งหมดซ้ำ ๆ ไม่ใช่การเลือกครั้งเดียว เช่น แอปโทรศัพท์หรือแชต การระบุผู้โทรเข้า หรือการสำรองและซิงก์ผู้ติดต่อ ถ้าแอปของคุณไม่ใช่แบบนั้น ก็มีแนวโน้มสูงว่าตัวเลือกของระบบจะพอ
การทำ Contact Picker ของ Android 17 ใน Flutter
ลองยกตัวอย่างแอปทดสอบง่าย ๆ ที่แค่แสดงรายชื่อผู้ติดต่อจากสมุดรายชื่อ:
ถ้าเราอยากได้ผู้ติดต่อแบบ «ดั้งเดิม» เราใช้ปลั๊กอินอย่าง flutter_contacts ได้:
final deviceContacts = await FlutterContacts.getAll(
properties: {ContactProperty.photoThumbnail},
);
โค้ดนี้จะให้รายชื่อผู้ติดต่อพร้อมชื่อและรูปภาพ แต่ก่อนอื่นต้องขอสิทธิ์จากผู้ใช้ก่อน ไม่อย่างนั้นการเรียกนี้จะไม่คืนอะไรกลับมาเลย:
final status = await FlutterContacts.permissions.request(PermissionType.read);
ทีนี้มาปรับให้เข้ากับ Android 17+ กัน อย่างแรกเลยคือประกาศสิทธิ์ READ_CONTACTS เฉพาะสำหรับเครื่องที่ใช้ Android 16 หรือต่ำกว่า:
<uses-permission
android:name="android.permission.READ_CONTACTS"
android:maxSdkVersion="36" />
เราต้องคอมไพล์ด้วย SDK ของ Android 17 ด้วย เพราะคลาส ContactsPickerSessionContract ไม่มีอยู่ในเวอร์ชันก่อนหน้า Flutter ยังใช้ 36 เป็นค่าเริ่มต้น เราจึงต้องตั้งเองใน build.gradle.kts ของแอป:
android {
compileSdk = 37
defaultConfig {
targetSdk = 37
}
}
เรียกตัวเลือกจาก Kotlin ด้วย ACTION_PICK_CONTACTS
จริง ๆ เราจะใช้ปลั๊กอินสำหรับเรื่องนี้ก็ได้ แต่เนื่องจากงานไม่ได้ยากอะไร ผมเลยเลือกเขียนเนทีฟเองเพื่อไม่ต้องพึ่งปลั๊กอินเพิ่มอีกตัว ฝั่งเนทีฟกับฝั่ง Dart คุยกันผ่าน method channel และเราเรียกตัวเลือกของระบบจาก Kotlin นี่แหละ โดยตั้งค่าให้เลือกได้หลายรายการแบบนี้:
val intent = Intent(ContactsPickerSessionContract.ACTION_PICK_CONTACTS).apply {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
putExtra(
ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_SELECTION_LIMIT,
call.arguments as Int,
)
// Read back only the fields below instead of every data field.
putExtra(
ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_MATCH_ALL_DATA_FIELDS,
false,
)
putStringArrayListExtra(
ContactsPickerSessionContract.EXTRA_PICK_CONTACTS_REQUESTED_DATA_FIELDS,
arrayListOf(
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE,
),
)
}
startActivityForResult(intent, PICK_CONTACTS_REQUEST_CODE)
เจ้า call.arguments as Int นั่นคือจำนวนสูงสุดที่เลือกได้ ซึ่งส่งมาจาก Dart ผ่าน method channel
เมื่อผู้ใช้เลือกเสร็จ ตัวเลือกจะไม่คืนผู้ติดต่อมาให้ตรง ๆ แต่คืน session URI ที่ชี้ไปยังรายการที่เลือกไว้ เราต้องไปอ่านมันด้วย ContentResolver และนี่คือจุดสำคัญ เคอร์เซอร์จะให้ หนึ่งแถวต่อหนึ่งฟิลด์ที่เราขอ ไม่ใช่หนึ่งแถวต่อหนึ่งผู้ติดต่อ เราจึงต้องจัดกลุ่มแถวเอง:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
val sessionUri = data?.data
if (resultCode != Activity.RESULT_OK || sessionUri == null) {
// The user left the picker without confirming a selection.
return
}
val projection = arrayOf(
ContactsContract.Contacts.LOOKUP_KEY,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Photo.PHOTO,
)
contentResolver.query(sessionUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
// Group the rows by LOOKUP_KEY, and keep the blob of the row whose
// MIMETYPE is Photo.CONTENT_ITEM_TYPE as the contact photo.
}
}
}
อย่าลืมว่าคิวรีนี้คือ I/O ดังนั้นควรย้ายออกจากเธรดหลักก่อนจะตอบกลับไปยัง Dart
ด้วยโค้ดนี้เราก็ได้ผู้ติดต่อพร้อมชื่อและรูปถ่ายแล้ว สำหรับตัวอย่างข้างต้นไม่ต้องการอะไรมากกว่านี้
อ่านผู้ติดต่อที่เลือกไว้ในฝั่ง Dart
ต่อมาในโค้ด Dart ของเรา มานิยามเมธอดที่จะบอกว่าควรใช้ตัวเลือกของระบบหรือปลั๊กอินแบบเดิม ซึ่งต้องรู้เวอร์ชัน Android ปัจจุบันก่อน และทำได้ด้วยปลั๊กอิน device_info_plus:
static const _systemPickerMinAndroidSdk = 37;
Future<bool> _isSystemPickerAvailable() async {
if (!Platform.isAndroid) return false;
final androidInfo = await DeviceInfoPlugin().androidInfo;
return androidInfo.version.sdkInt >= _systemPickerMinAndroidSdk;
}
เท่านี้เราก็ดึงผู้ติดต่อด้วยกลไกที่เหมาะสมได้แล้ว:
Future<List<AppContact>> loadContacts() async {
if (await _isSystemPickerAvailable()) {
return await _pickContactsWithSystemPicker();
}
return await _readContactsWithPermission();
}
บน Android 17+ เราดึงผู้ติดต่อจากฝั่งเนทีฟแล้วแปลงข้อมูล:
/// Opens the system Contact Picker. Returns an empty list if the user leaves
/// without selecting anything.
Future<List<AppContact>> _pickContactsWithSystemPicker() async {
final response = await _channel.invokeMapMethod<String, Object?>(
_methodPickContacts,
_maxSelectedContacts,
);
if (response == null || response['wasCancelled'] == true) {
return const [];
}
final contacts = <AppContact>[];
for (final rawContact in response['contacts'] as List<Object?>) {
final contact = rawContact as Map<Object?, Object?>;
final name = contact['name'] as String;
if (name.isEmpty) continue;
contacts.add(
AppContact(
id: contact['id'] as String,
name: name,
photo: contact['photo'] as Uint8List?,
),
);
}
return contacts;
}
อีกเรื่องสุดท้ายที่ต้องจำไว้ ตัวเลือกนี้ผู้ใช้เป็นคนเปิด ไม่ใช่แอป คุณจะเรียกมันขึ้นมาเองเพื่อรีเฟรชผู้ติดต่อเบื้องหลังไม่ได้ ดังนั้นถ้าต้องใช้รายการที่เลือกไว้ทีหลัง ก็เก็บมันไว้เสีย
ตรงนี้ผมแสดงให้ดูแค่ส่วนสำคัญ แต่ถ้าอยากเข้าใจการพัฒนาให้ดีขึ้น ดูโปรเจกต์ฉบับเต็มได้ที่นี่
สรุป
ในบทความนี้เราไล่ดูนโยบายใหม่ของ Google Play เรื่องสิทธิ์การอ่านผู้ติดต่อกันอย่างรวดเร็ว และผมได้แสดงวิธีง่าย ๆ ที่ผมเองใช้ในการทำงานกับผู้ติดต่อในแอปของผม
ถ้าคุณคิดว่าจำเป็นต้องใช้สิทธิ์ผู้ติดต่อจริง ๆ อย่าลืมว่าคุณต้องกรอกแบบฟอร์มสำหรับเรื่องนี้ ใน Google Play ก่อนที่นโยบายจะมีผลในวันที่ 27 มกราคม 2027
แล้วพบกันใหม่คราวหน้า!