優雅地封裝 Activity Result API,完美地替代 startActivityForResult()

DylanCai發表於2021-07-22

前言

之前寫了兩篇文章講了 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 裡。

demo-qr-code.png

screenshot

下面來介紹 Kotlin 的用法,Java 的用法可以檢視 Wiki 文件

在根目錄的 build.gradle 新增:

allprojects {
    repositories {
        // ...
        maven { url 'https://www.jitpack.io' }
    }
}
複製程式碼

新增依賴:

dependencies {
    implementation 'com.github.DylanCaiCoding:ActivityResultLauncher:1.0.0'
}
複製程式碼

用法也只有簡單的兩步:

第一步,在 ComponentActivityFragment 建立對應的物件,需要注意建立物件的時機要在 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 支援一下喲 ~ 我後面會分享更多封裝相關的文章給大家。

相關文章