/ 今日科技快讯 /
近日,腾讯控股旗下游戏《王者荣耀》的健康系统升级。根据其发布的公告,在国家新闻出版署《关于防止未成年人沉迷网络游戏的通知》相关规定的基础上,腾讯为进一步加大未成年人的保护力度,将升级健康系统规则。未成年用户每日22时至次日8时禁玩,国家法定节假日每日限玩2小时,其他时间每日限玩1小时。
/ 作者简介 /
本篇文章来自DylanCai的投稿,文章主要分享了他对Activity Result API的封装的整个过程和心得,相信会对大家有所帮助!
DylanCai的博客地址:
https://juejin.cn/user/4195392100243000
/ 开始 /
之前写了两篇文章讲了 ViewBinding 的封装,这是 Jetpack 的一个组件,用于替代 findViewById、ButterKnife、KAE。不过用到了些 Kotlin 相对进阶点的用法,可能有些不太熟悉 Kotlin 的小伙伴看不太懂封装的代码。
所以这次来讲些简单一点的封装,来封装 Jetpack 的另一个组件——Activity Result API。这是官方用于替代 startActivityForResult() 和 onActivityResult() 的工具,能替代但是不够好用,有些小伙伴看了后还是选择写 startActivityForResult()。需要封装优化一下用法,但是推出大半年了个人没看到比较好用的封装。最初有很多人会用拓展函数进行封装,而在 activity-ktx:1.2.0-beta02 版本之后,调用注册方法的时机必须在 onStart() 之前,原来的拓展函数就不适用了,在这之后就没看到有人封装了。
个人对 Activity Result API 的封装思考了很久,已经尽量做到在 Kotlin 和 Java 都足够地好用,可以完美替代 startActivityForResult() 了。下面带着大家一起来封装 Activity Result API。
/ 基础用法 /
首先要了解 Activity Result API 的用法。添加依赖:
dependencies {
implementation "androidx.activity:activity-ktx:1.2.4"
}
在 ComponentActivity 或 Fragment 中调用 Activity Result API 提供的 registerForActivityResult() 方法注册结果回调(在 onStart() 之前调用)。该方法接收 ActivityResultContract 和 ActivityResultCallback 参数,返回可以启动另一个 activity 的 ActivityResultLauncher 对象。
val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
// Handle the returned Uri
}
ActivityResultContract 协议类定义生成结果所需的输入类型以及结果的输出类型,Activity Result API 已经提供了很多默认的协议类,方便大家实现请求权限、拍照等常见操作。
只是注册回调并不会启动另一个 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 提供了许多默认的协议类:
我们还可以自定义协议类,继承 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)
}
}
自定义协议类实现后,就能调用 registerForActivityResult() 和 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 。
https://github.com/DylanCaiCoding/ActivityResultLauncher
方便大家使用。还新增和完善了一些功能,有以下特点:
完美替代 startActivityForResult()
支持 Kotlin 和 Java 用法
支持请求权限
支持拍照(已适配 Android 10)
支持录像(已适配 Android 10)
支持选择图片或视频
支持裁剪图片(已适配 Android11)
支持打开蓝牙
支持打开定位
支持使用存储访问框架 SAF
支持选择联系人
个人写了个 Demo 给大家来演示有什么功能,完整的代码在 Github 里。
下面来介绍 Kotlin 的用法,Java 的用法可以查看 Wiki 文档。
https://github.com/DylanCaiCoding/ActivityResultLauncherhttps://github.com/DylanCaiCoding/ActivityResultLauncher/wiki/%E5%9F%BA%E7%A1%80%E7%94%A8%E6%B3%95-(Java)
在根目录的 build.gradle 添加:
allprojects {
repositories {
// ...
maven { url 'https://www.jitpack.io' }
}
}
添加依赖:
dependencies {
implementation 'com.github.DylanCaiCoding:ActivityResultLauncher:1.0.1'
}
用法也只有简单的两步:
第一步,在 ComponentActivity 或 Fragment 创建对应的对象,需要注意创建对象的时机要在 onStart() 之前。例如创建通用的启动器:
private val startActivityLauncher = StartActivityLauncher(this)
提供以下默认的启动器类:
第二步,调用启动器对象的 launch() 方法。比如跳转一个输入文字的页面,点击保存按钮回调结果。我们用 StartActivityLauncher 替换掉原来 startActivityForResult() 的写法。
val intent = Intent(this, InputTextActivity::class.java)
intent.putExtra(KEY_NAME, "nickname")
startActivityLauncher.launch(intent) { activityResult ->
if (activityResult.resultCode == RESULT_OK) {
activityResult.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,返回的数组不为空数组等。
还有一些常用的功能,比如调用系统相机拍照,保存到外置存储的应用缓存目录。
takePictureLauncher.launch { uri ->
if (uri != null) {
// 拍照成功,上传或取消等操作后建议把缓存文件删除
}
}
或者拍照保存到系统相册,已适配 Android 10。
takePictureLauncher.launchForMediaImage { uri ->
if (uri != null) {
// 拍照成功
}
}
调用系统相册选择图片,增加了申请读取权限的操作。
pickContentLauncher.launchForImage(
onActivityResult = { uri ->
if (uri != null) {
// 处理 uri
}
},
onPermissionDenied = {
// 拒绝了读取权限且不再询问,可引导用户到设置里授权该权限
},
onExplainRequestPermission = {
// 拒绝了一次读取权限,可弹框解释为什么要获取该权限
}
)
能更方便地使用存储访问框架 SAF,在 Android 10 或以上访问共享储存空间的文档文件会用到。
个人也新增了些功能,比如裁剪头像,通常上传头像要裁剪成 1:1 比例,已适配 Android 11。
cropPictureLauncher.launch(inputUri) { uri ->
if (uri != null) {
// 裁剪成功
}
}
还有开启蓝牙功能,能更容易地开启蓝牙并且确保蓝牙功能是可用的(需要授权定位权限和确保定位已打开)。
enableBluetoothLauncher.launchAndEnableLocation(
"为保证蓝牙正常使用,请开启定位", // 已授权权限但未开启定位,会跳转对应设置页面,并吐司该字符串
onLocationEnabled= { enabled ->
if (enabled) {
// 已开启了蓝牙,并且授权了位置权限和打开了定位
}
},
onPermissionDenied = {
// 拒绝了位置权限且不再询问,可引导用户到设置里授权该权限
},
onExplainRequestPermission = {
// 拒绝了一次位置权限,可弹框解释为什么要获取该权限
}
)
更多的用法请查看 Wiki 文档 。
原本 Activity Result API 已经有很多默认的协议类,都封装了对应的启动器类。大家可能不会用到所有类,不过开了混淆会自动移除没有使用的类。
后续还会支持用 Kotlin Flow 回调数据,能更方便地写流式编程的代码。用法上我还在斟酌,有兴趣的可以关注一下。如果有其它使用场景或者别的想法可以在 Github 提 issue,我会继续完善的。
/ 彩蛋 /
个人之前封装过一个 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 {
val fragmentManager = activity.supportFragmentManager
var fragment = fragmentManager.findFragmentByTag(TAG) as DispatchResultFragment?
if (fragment == null) {
fragment = DispatchResultFragment()
fragmentManager.beginTransaction().add(fragment, TAG).commitAllowingStateLoss()
fragmentManager.executePendingTransactions()
}
return fragment
}
}
}
如果觉得 Activity Result API 比较复杂,也可以拷贝这个去用。不过 requestCode 处理得不够好,而且很多功能需要自己额外去实现,用起来可能没那么方便。
/ 总结 /
本文讲了 Activity Result API 的基础用法,虽然能替代 startActivityForResult() 和 onActivityResult(),但没有做到足够地好用,有些人还宁愿继续使用 startActivityForResult()。之后分享了个人的封装思路,介绍了个人封装的库 ActivityResultLauncher,使 Activity Result API 更加简洁易用,能完美地替代 startActivityForResult()。
推荐阅读:
PermissionX 1.5发布,支持申请Android特殊权限啦
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注