前言
之前寫了兩篇文章講了 ViewBinding 的封裝,這是 Jetpack 的一個元件,用於替代 findViewById、ButterKnife、KAE。不過用到了些 Kotlin 相對進階點的用法,可能有些不太熟悉 Kotlin 的小夥伴看不太懂封裝的程式碼。
所以這次來講些簡單一點的封裝,來封裝 Jetpack 的另一個元件——Activity Result API。這是官方用於替代 startActivityForResult()
和 onActivityResult()
的。雖然出了有大半年了,但是個人到現在沒看到比較好用的封裝。最初大多數人會用擴充函式進行封裝,而在 activity-ktx:1.2.0-beta02
版本之後,呼叫註冊方法的時機必須在 onStart()
之前,原來的擴充函式就不適用了,在這之後就沒看到有人進行封裝了。
個人對 Activity Result API 的封裝思考了很久,已經儘量做到在 Kotlin 和 Java 都足夠地好用,可以完美替代 startActivityForResult()
了。下面帶著大家一起來封裝 Activity Result API。
基礎用法
首先要先了解基礎的用法,在 ComponentActivity
或 Fragment
中呼叫 Activity Result API 提供的 registerForActivityResult()
方法註冊結果回撥(在 onStart() 之前呼叫)。該方法接收 ActivityResultContract
和 ActivityResultCallback
引數,返回可以啟動另一個 activity 的 ActivityResultLauncher
物件。
ActivityResultContract
協議類定義生成結果所需的輸入型別以及結果的輸出型別,Activity Result API 已經提供了很多預設的協議類,方便大家實現請求許可權、拍照等常見操作。
val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
// Handle the returned Uri
}
複製程式碼
只是註冊回撥並不會啟動另一個 activity ,還要呼叫 ActivityResultLauncher#launch()
方法才會啟動。傳入協議類定義的輸入引數,當使用者完成後續 activity 的操作並返回時,將執行 ActivityResultCallback
中的 onActivityResult()
回撥方法。
getContent.launch("image/*")
複製程式碼
完整的使用程式碼:
val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
// Handle the returned Uri
}
override fun onCreate(savedInstanceState: Bundle?) {
// ...
selectButton.setOnClickListener {
getContent.launch("image/*")
}
}
複製程式碼
ActivityResultContracts
提供了許多預設的協議類:
協議類 | 作用 |
---|---|
RequestPermission() | 請求單個許可權 |
RequestMultiplePermissions() | 請求多個許可權 |
TakePicturePreview() | 拍照預覽,返回 Bitmap |
TakePicture() | 拍照,返回 Uri |
TakeVideo() | 錄影,返回 Uri |
GetContent() | 獲取單個內容檔案 |
GetMultipleContents() | 獲取多個內容檔案 |
CreateDocument() | 建立文件 |
OpenDocument() | 開啟單個文件 |
OpenMultipleDocuments() | 開啟多個文件 |
OpenDocumentTree() | 開啟文件目錄 |
PickContact() | 選擇聯絡人 |
StartActivityForResult() | 通用協議 |
我們還可以自定義協議類,繼承 ActivityResultContract
,定義輸入和輸出類。如果不需要任何輸入,可使用 Void
或 Unit
作為輸入型別。需要實現兩個方法,用於建立與 startActivityForResult()
配合使用的 Intent
和解析輸出的結果。
class PickRingtone : ActivityResultContract<Int, Uri?>() {
override fun createIntent(context: Context, ringtoneType: Int) =
Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, ringtoneType)
}
override fun parseResult(resultCode: Int, result: Intent?) : Uri? {
if (resultCode != Activity.RESULT_OK) {
return null
}
return result?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
}
複製程式碼
自定義協議類實現後,就能呼叫註冊方法和 launch()
方法進行使用。
val pickRingtone = registerForActivityResult(PickRingtone()) { uri: Uri? ->
// Handle the returned Uri
}
複製程式碼
pickRingtone.launch(ringtoneType)
複製程式碼
不想自定義協議類的話,可以使用通用的協議 ActivityResultContracts.StartActivityForResult()
,實現類似於之前 startActivityForResult()
的功能。
val startForResult = registerForActivityResult(StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
val intent = result.intent
// Handle the Intent
}
}
複製程式碼
startForResult.launch(Intent(this, InputTextActivity::class.java))
複製程式碼
封裝思路
為什麼要封裝?
看完上面的用法,不知道大家會不會和我初次瞭解的時候一樣,感覺比原來複雜很多。
主要是引入的新概念比較多,原來只需要瞭解 startActivityForResult()
和 onActivityResult()
的用法,現在要了解一大堆類是做什麼的,學習成本高了不少。
用法也有些奇怪,比如官方示例用註冊方法得到一個叫 getContent
物件,這更像是函式的命名,還要用這個物件去呼叫 launch()
方法,程式碼閱讀起來總感覺怪怪的。
而且有個地方個人覺得不是很好,callback 居然在 registerForActivityResult()
方法裡傳。個人覺得 callback 在 launch()
方法裡傳更符合習慣,邏輯也更加連貫,程式碼閱讀性更好。最好改成下面的用法,啟動後就接著處理結果的邏輯。
getContent.launch("image/*") { uri: Uri? ->
// Handle the returned Uri
}
複製程式碼
所以還是有必要對 Activity Result API 進行封裝的。
怎麼封裝?
首先是修改 callback 傳參的位置,實現思路也比較簡單,過載 launch() 方法加一個 callback 引數,用個變數快取起來。在回撥的時候拿快取的 callback 物件去執行。
private var callback: ActivityResultCallback<O>? = null
fun launch(input: I?, callback: ActivityResultCallback<O>) {
this.callback = callback
launcher.launch(input)
}
複製程式碼
由於需要快取 callback 物件,還要寫一個類來持有該快取變數。
有一個不好處理的問題是 registerForActivityResult()
需要的 onStart() 之前呼叫。可以通過 lifecycle 在 onCreate() 的時候自動註冊,但是個人思考了好久並沒有想到更優的實現方式。就是獲取 lifecycleOwner 觀察宣告週期自動註冊,也是需要在 onStart() 之前呼叫,那為什麼不直接執行註冊方法呢?所以個人改變了思路,不糾結於自動註冊,而是簡化註冊的程式碼。
前面說了需要再寫一個類快取 callback 物件,使用一個類的時候有個方法基本會用到,就是建構函式。我們可以在建立物件的時候進行註冊。
註冊方法需要 callback 和協議類物件兩個引數,callback 是從 launch()
方法得到,而協議類物件就需要傳了。這樣用起來個人覺得還不夠友好,綜合考慮後決定用繼承的方式把協議類物件給“隱藏”了。
最終得到以下的基類。
public class BaseActivityResultLauncher<I, O> {
private final ActivityResultLauncher<I> launcher;
private ActivityResultCallback<O> callback;
public BaseActivityResultLauncher(ActivityResultCaller caller, ActivityResultContract<I, O> contract) {
launcher = caller.registerForActivityResult(contract, (result) -> {
if (callback != null) {
callback.onActivityResult(result);
callback = null;
}
});
}
public void launch(@SuppressLint("UnknownNullness") I input, @NonNull ActivityResultCallback<O> callback) {
this.callback = callback;
launcher.launch(input);
}
}
複製程式碼
改用了 Java 程式碼來實現,返回的結果可以判空也可以不判空,比如返回陣列的時候一定不為空,只是陣列大小為 0 。用 Kotlin 實現的話要寫兩個不同名的方法來應對這個情況,使用起來並不是很方便。
這是多增加一個封裝的步驟來簡化後續的使用,原本只是繼承 ActivityResultContract
實現協議類,現在還需要再寫一個啟動器類繼承 BaseActivityResultLauncher
。
比如用前面獲取圖片的示例,我們再封裝一個 GetContentLauncher
類。
class GetContentLauncher(caller: ActivityResultCaller) :
BaseActivityResultLauncher<String, Uri>(caller, GetContent())
複製程式碼
只需這麼簡單的繼承封裝,後續使用就更加簡潔易用了。
val getContentLauncher = GetContentLauncher(this)
override fun onCreate(savedInstanceState: Bundle?) {
// ...
selectButton.setOnClickListener {
getContentLauncher.launch("image/*") { uri: Uri? ->
// Handle the returned Uri
}
}
}
複製程式碼
再封裝一個 Launcher 類的好處是,能更方便地過載 launch()
方法,比如在類裡增加一個方法在獲取圖片之前會先授權讀取許可權。如果改用 Kotlin 擴充函式來實現,在 Java 會更加難用。Launcher 類能對 Java 用法進行兼顧。
最後總結一下,對比原本 Activity Result API 的用法,改善了什麼問題:
- 簡化冗長的註冊程式碼,改成簡單地建立一個物件;
- 改善物件的命名,比如官方示例命名為
getContent
物件就很奇怪,這通常是函式的命名。優化後很自然地用類名來命名為getContentLauncher
,使用一個啟動器物件呼叫launch()
方法會更加合理; - 改變回撥的位置,使其更加符合使用習慣,邏輯更加連貫,程式碼閱讀性更好;
- 輸入引數和輸出引數不會限制為一個物件,可以過載方法簡化用法;
- 能更方便地整合多個啟動器的功能,比如獲取讀取許可權後再跳轉相簿選擇圖片;
最終用法
由於 Activity Result API 已有很多的協議類,如果每一個協議都去封裝一個啟動器類會有點麻煩,所以個人已經寫好一個庫 ActivityResultLauncher 方便大家使用。還新增和完善了一些功能,有以下特點:
- 完美替代
startActivityForResult()
- 支援 Kotlin 和 Java 用法
- 支援請求許可權
- 支援拍照
- 支援錄影
- 支援選擇圖片或視訊(已適配 Android 10)
- 支援裁剪圖片(已適配 Android11)
- 支援開啟藍芽
- 支援開啟定位
- 支援使用儲存訪問框架 SAF
- 支援選擇聯絡人
個人寫了個 Demo 給大家來演示有什麼功能,完整的程式碼在 Github 裡。
下面來介紹 Kotlin 的用法,Java 的用法可以檢視 Wiki 文件。
在根目錄的 build.gradle 新增:
allprojects {
repositories {
// ...
maven { url 'https://www.jitpack.io' }
}
}
複製程式碼
新增依賴:
dependencies {
implementation 'com.github.DylanCaiCoding:ActivityResultLauncher:1.0.0'
}
複製程式碼
用法也只有簡單的兩步:
第一步,在 ComponentActivity
或 Fragment
建立對應的物件,需要注意建立物件的時機要在 onStart()
之前。例如建立通用的啟動器:
private val startActivityLauncher = StartActivityLauncher(this)
複製程式碼
提供以下預設的啟動器類:
啟動器 | 作用 |
---|---|
StartActivityLauncher | 完美替代 startActivityForResult() |
TakePicturePreviewLauncher | 呼叫系統相機拍照預覽,只返回 Bitmap |
TakePictureLauncher | 呼叫系統相機拍照 |
TakeVideoLauncher | 呼叫系統相機錄影 |
PickContentLauncher, GetContentLauncher | 選擇單個圖片或視訊,已適配 Android 10 |
GetMultipleContentsLauncher | 選擇多個圖片或視訊,已適配 Android 10 |
CropPictureLauncher | 裁剪圖片,已適配 Android 11 |
RequestPermissionLauncher | 請求單個許可權 |
RequestMultiplePermissionsLauncher | 請求多個許可權 |
AppDetailsSettingsLauncher | 開啟系統設定的 App 詳情頁 |
EnableBluetoothLauncher | 開啟藍芽 |
EnableLocationLauncher | 開啟定位 |
CreateDocumentLauncher | 建立文件 |
OpenDocumentLauncher | 開啟單個文件 |
OpenMultipleDocumentsLauncher | 開啟多個文件 |
OpenDocumentTreeLauncher | 訪問目錄內容 |
PickContactLauncher | 選擇聯絡人 |
StartIntentSenderLauncher | 替代 startIntentSender() |
第二步,呼叫啟動器物件的 launch()
方法。
比如跳轉一個輸入文字的頁面,點選儲存按鈕回撥結果。我們替換掉原來 startActivityForResult()
的寫法。
val intent = Intent(this, InputTextActivity::class.java)
intent.putExtra(KEY_NAME, "nickname")
startActivityLauncher.launch(intent) { activityResult ->
if (activityResult.resultCode == RESULT_OK) {
data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
}
}
複製程式碼
為了方便使用,有些啟動器會增加一些更易用的 launch()
方法。比如這個例子能改成下面更簡潔的寫法。
startActivityLauncher.launch<InputTextActivity>(KEY_NAME to "nickname") { resultCode, data ->
if (resultCode == RESULT_OK) {
data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
}
}
複製程式碼
由於輸入文字頁面可能有多個地方需要跳轉複用,我們可以用前面的封裝思路,自定義實現一個 InputTextLauncher
類,進一步簡化呼叫的程式碼,只關心輸入值和輸出值,不用再處理跳轉和解析過程。
inputTextLauncher.launch("nickname") { value ->
if (value != null) {
toast(value)
}
}
複製程式碼
通常要對返回值進行判斷,因為可能會有取消操作,要判斷是不是被取消了。比如返回的 Boolean 要為 true,返回的 Uri 不為 null,返回的陣列不為空陣列等。
還有一些常用的功能,比如呼叫系統相機拍照和跳轉系統相簿選擇圖片,已適配 Android 10,可以直接得到 uri 來載入圖片和用 file 進行上傳等操作。
takePictureLauncher.launch { uri, file ->
if (uri != null && file != null) {
// 上傳或取消等操作後建議把快取檔案刪除,呼叫 file.delete()
}
}
複製程式碼
pickContentLauncher.launchForImage(
onActivityResult = { uri, file ->
if (uri != null && file != null) {
// 上傳或取消等操作後建議把快取檔案刪除,呼叫 file.delete()
}
},
onPermissionDenied = {
// 拒絕了讀取許可權且不再詢問,可引導使用者到設定裡授權該許可權
},
onExplainRequestPermission = {
// 拒絕了一次讀取許可權,可彈框解釋為什麼要獲取該許可權
}
)
複製程式碼
個人也新增了些功能,比如裁剪圖片,通常上傳頭像要裁剪成 1:1 比例,已適配 Android 11。
cropPictureLauncher.launch { uri, file ->
if (uri != null && file != null) {
// 上傳或取消等操作後建議把快取檔案刪除,呼叫 file.delete()
}
}
複製程式碼
還有開啟藍芽功能,能更容易地開啟藍芽和確保藍芽功能是可用的(需要授權定位許可權和確保定位已開啟)。
enableBluetoothLauncher.launchAndEnableLocation(
"為保證藍芽正常使用,請開啟定位", // 已授權許可權但未開啟定位,會跳轉對應設定頁面,並吐司該字串
onLocationEnabled= { enabled ->
if (enabled) {
// 已開啟了藍芽,並且授權了位置許可權和開啟了定位
}
},
onPermissionDenied = {
// 拒絕了位置許可權且不再詢問,可引導使用者到設定裡授權該許可權
},
onExplainRequestPermission = {
// 拒絕了一次位置許可權,可彈框解釋為什麼要獲取該許可權
}
)
複製程式碼
更多的用法請檢視 Wiki 文件 。
原本 Activity Result API 已經有很多預設的協議類,都封裝了對應的啟動器類。大家可能不會用到所有類,開了混淆會自動移除沒使用到的類。
彩蛋
個人之前封裝過一個 startActivityForResult()
擴充函式,可以直接在後面寫回撥邏輯。
startActivityForResult(intent, requestCode) { resultCode, data ->
// Handle result
}
複製程式碼
下面是實現的程式碼,使用一個 Fragment 來分發 onActivityResult 的結果。程式碼量不多,邏輯應該比較清晰,感興趣的可以瞭解一下,Activity Result API 的實現原理應該也是類似的。
inline fun FragmentActivity.startActivityForResult(
intent: Intent,
requestCode: Int,
noinline callback: (resultCode: Int, data: Intent?) -> Unit
) =
DispatchResultFragment.getInstance(this).startActivityForResult(intent, requestCode, callback)
class DispatchResultFragment : Fragment() {
private val callbacks = SparseArray<(resultCode: Int, data: Intent?) -> Unit>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
fun startActivityForResult(
intent: Intent,
requestCode: Int,
callback: (resultCode: Int, data: Intent?) -> Unit
) {
callbacks.put(requestCode, callback)
startActivityForResult(intent, requestCode)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
val callback = callbacks.get(requestCode)
if (callback != null) {
callback.invoke(resultCode, data)
callbacks.remove(requestCode)
}
}
companion object {
private const val TAG = "dispatch_result"
fun getInstance(activity: FragmentActivity): DispatchResultFragment =
activity.run {
val fragmentManager = supportFragmentManager
var fragment = fragmentManager.findFragmentByTag(TAG) as DispatchResultFragment?
if (fragment == null) {
fragment = DispatchResultFragment()
fragmentManager.beginTransaction().add(fragment, TAG).commitAllowingStateLoss()
fragmentManager.executePendingTransactions()
}
fragment
}
}
}
複製程式碼
如果覺得 Activity Result API 比較複雜,也可以拷貝這個去用。不過 requestCode 處理得不夠好,而且很多功能需要自己額外去實現,用起來可能沒那麼方便。
往期講解封裝的文章
總結
本文講了 Activity Result API 的基礎用法,雖然能替代 startActivityForResult()
和 onActivityResult()
,但是並沒有足夠地好用,有些人還寧願繼續用 startActivityForResult()
。然後分享了個人的封裝思路,介紹了個人封裝的庫 ActivityResultLauncher,使 Activity Result API 更加簡潔易用,能完美地替代 startActivityForResult()
。如果您覺得有幫助的話,希望能點個 star 支援一下喲 ~ 我後面會分享更多封裝相關的文章給大家。