ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android Activity通信全解析:从Intent到Activity Result API与常见坑

Android Activity通信全解析:从Intent到Activity Result API与常见坑 写Intent传参、写startActivityForResult、写Bundle里面塞数据这是每一个Android开发入门时都会做的事。但真正到了多人协作、页面架构复杂、还要考虑进程被杀恢复的场景Activity之间的通信就没那么简单——既要从几十个页面里把参数理清楚又要防止大对象撑爆Bundle还要处理Activity销毁重建后数据丢失的问题。这篇文章不打算重复官方文档里那些入门示例而是把我在实际项目里反复踩过、验证过的通信方案从原理到代码一层一层拆开讲清楚希望能给正在被Activity通信折磨的人一些参考。先说清楚适用人群。刚接触Android的初学者可以重点看显式Intent和Activity Result API的用法有了一段时间开发经验、正在做页面重构或者遇到内存浪费、数据恢复问题的人可以直接跳到跨Activity共享数据和常见坑那两章。文章里所有代码都基于Android Studio最新稳定版最低支持API 21覆盖目前绝大多数机型。1. 通信方案全景从Intent到事件总线怎么选才对Activity之间的通信说到底就两件事把数据带过去把结果拿回来。围绕这两件事Android生态里演化出了一整套方案各有各的适用场景也各有各的代价。用对了顺手用错了后期维护就是无底洞。1.1 Intent传参为什么是默认选项Intent是Android系统中最基础的组件通信载体它本身就是一个数据容器可以携带Action、Data、Category以及一个专门用于传递数据的Bundle。从Activity A跳转到Activity B本质上是把当前组件的控制权交给系统由系统根据Intent的描述去创建并启动目标组件。在这个过程中Intent所携带的数据会经过系统进程做一次序列化和反序列化再交到目标Activity。为什么它是默认选项因为它跟Activity生命周期咬得最紧。系统在创建目标Activity时同步把数据送过去目标Activity只要在onCreate里取出数据即可天然有序、可靠不依赖任何第三方类库。而且它没有什么学习成本官方文档、博客、甚至是招聘笔试里显式Intent传参都是必考内容你找不到比它更通用、更容易被接手的方式。当然这个方案也有明确的瓶颈Intent一次能携带的数据大小有限过大的数据会抛出TransactionTooLargeException这个后面单独讲。另外Intent传参在编译期没有任何类型校验key写错、类型对不上都要运行时才能发现所以在多人协作的项目里建议给每个Activity的Intent参数统一封装成一个静态方法让调用方一看就懂该传什么、不该传什么。1.2 除了Intent还有哪些路可以走除了Intent业界常用的还有几种startActivityForResult / Activity Result API用于拿回目标页面处理后的结果比如相册选图、扫码返回、表单填写后的回填。ViewModel适合同一个Activity内部多个Fragment之间的数据共享也适合配合LiveData做数据刷新通知但它不是跨Activity的通用方案。单例或全局Application适合保存登录态、用户信息等全局性数据但使用不当极容易引起内存泄漏和状态污染。事件总线EventBus、RxBus适合解耦多对多的消息传递但因为跳过了系统的生命周期管理调试起来比较痛苦容易出“收不到事件”或“内存泄漏”的问题。文件、数据库、ContentProvider适合大数据量或跨进程通信但操作成本高不适合做简单的页面间参数传递。系统级方案如BroadcastReceiver、AIDL、Socket等这些一般用于跨进程或系统级通信普通Activity间通信基本用不上用了反而是过度设计。从这张对照表就能看出来页间数据传递Intent永远是第一选择拿结果就用Activity Result API页面内部共享数据才考虑ViewModel别一上来就上事件总线否则等你在几十个事件名里排查一个空指针时会后悔的。2. 显式Intent和隐式Intent的完整用法很多人做了几年开发还在“能跑就行”的状态对显式和隐式的边界完全没概念。这里花点篇幅把两种方式掰开揉碎了讲因为它们各自的匹配规则、出错方式、使用场景完全不同。2.1 显式Intent写法与Bundle传参的实操显式Intent的核心在于明确指定目标组件一般是包名加类名目标明确、无歧义。常用写法有两种// 方式一通过构造函数指定 val intent Intent(this, DetailActivity::class.java) intent.putExtra(news_id, 10086) startActivity(intent) // 方式二先new一个Intent再setClass val intent Intent() intent.setClass(this, DetailActivity::class.java) intent.putExtra(news_id, 10086) startActivity(intent)从工程化角度我建议给每个目标Activity定义一个伴生方法把“启动这个Activity所需的全部参数”集中起来。这样调用方不需要知道key值也不容易漏传参数class DetailActivity : AppCompatActivity() { companion object { private const val EXTRA_NEWS_ID extra_news_id const val REQUEST_CODE_DETAIL 1001 fun start(context: Context, newsId: Long) { val intent Intent(context, DetailActivity::class.java) intent.putExtra(EXTRA_NEWS_ID, newsId) if (context !is Activity) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val newsId intent.getLongExtra(EXTRA_NEWS_ID, -1L) } }注意一个细节当context不是Activity而是Application时直接startActivity会报“Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag”这个错。我在写封装方法时习惯加一个类型判断避免外部误传导致崩溃。取数据时每一种基本类型都有对应的getter。默认值的设置特别重要因为当key不存在时返回的是默认值而不是抛异常。但这里有个坑如果你传了一个nullgetStringExtra会把它读成null而getStringExtra(key, 默认值)反而会把默认值覆盖成null。所以团队协作时尽量约束“要么别传要么传合法值”不要让key存在但值为null的情况发生。2.2 隐式Intent匹配规则与自定义协议隐式Intent不指定目标组件而是通过Action、Category、Data三个维度去描述“想做什么”由系统去匹配已经注册好的组件。比如最常见的打开浏览器、拨打电话、打开某个App页面都是通过隐式Intent完成的。自定义协议是隐式Intent最常见的使用场景。比如我们App里做了一个分享功能希望其他App通过URL来唤起一个展示页就可以在AndroidManifest里给Activity注册一个schemaactivity android:name.ShareReceiverActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schememyapp android:hostshare / /intent-filter /activity这样外部就可以通过myapp://share?urlhttps%3A%2F%2Fexample.com拉起这个页面。在代码里解析val uri: Uri? intent?.data val url uri?.getQueryParameter(url)这个方案在应用间跳转、H5唤起App场景中非常常用。但用隐式Intent有一个必须注意的点系统在匹配不到任何组件时会直接抛ActivityNotFoundException这个异常不像普通空指针那么好排查因为它是运行时动态匹配失败导致的。所以凡是隐式跳转建议先判断有没有App能处理val intent Intent(Intent.ACTION_VIEW, uri) if (intent.resolveActivity(packageManager) ! null) { startActivity(intent) } else { // 没有App能处理这个URI给用户一个友好提示 }还有一点要提醒自定义schema的隐式Intent非常容易被其他App恶意唤起所以如果不是刻意要对外暴露android:exported务必设为false需要暴露的也要在代码层做鉴权。2.3 Bundle传参Parcelable和Serializable到底选哪个Intent的putExtra最终会把数据放进一个Bundle里。Bundle在跨进程传输时需要把数据序列化成二进制流再在另一个进程中反序列化还原成对象。Android提供了两种序列化接口Java自带的Serializable和Android专属的Parcelable。Serializable用起来极简一个implements就没别的事了但背后是大量的反射序列化出来的数据体积大、速度慢。Parcelable需要手动写writeToParcel和CREATOR代码量多但它是针对Android的IPC场景设计的采用类似“打包”的方式直接写入内存块序列化速度快、体积小。在Activity之间传对象实际场景是跑在系统进程的Binder事务里所以强烈推荐Parcelable。写一个典型的学生对象data class Student( val name: String, val score: Int ) : Parcelable { constructor(parcel: Parcel) : this( parcel.readString() ?: , parcel.readInt() ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeString(name) parcel.writeInt(score) } override fun describeContents(): Int 0 companion object CREATOR : Parcelable.CreatorStudent { override fun createFromParcel(parcel: Parcel): Student Student(parcel) override fun newArray(size: Int): ArrayStudent? arrayOfNulls(size) } }现代开发一般都用了Kotlin推荐直接使用Parcelize注解配合kotlin-parcelize插件只需要一行声明Parcelize data class Student( val name: String, val score: Int ) : Parcelable编译器自动生成所有样板代码。省下的时间拿去解决真正的业务问题它不香吗3. 从Activity B拿回结果的全流程拆解传数据过去只是单向通道现实的业务场景里从B页面回传数据给A页面是刚需比如填写收货地址、选择优惠券、裁剪头像。对这个需求老一代开发者普遍用的是startActivityForResult onActivityResult而新项目现在都迁移到了Activity Result API。3.1 旧的startActivityForResult流程以及它为什么被替代老写法大致是// A页面发起 val intent Intent(this, PickCityActivity::class.java) startActivityForResult(intent, REQUEST_CODE_PICK_CITY) // A页面接收 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode REQUEST_CODE_PICK_CITY) { if (resultCode RESULT_OK) { val city data?.getStringExtra(city) // 处理结果 } } }这个方案最大的痛点在于requestCode是纯int值写多了之后根本分不清哪个code对应哪个页面一旦在多层Fragment、Activity之间流转代码里会堆满魔法数字。而且如果A页面因为内存不足被系统回收了返回结果时onActivityResult拿到的可能是一个已经丢失状态的Activity数据处理就会混乱。为了让结果回调跟生命周期能正确配合官方终于在AndroidX Activity 1.2.0中推出了Activity Result API用它来彻底替代旧的写法。3.2 Activity Result API新写法真比老写法香很多新API的思路是把“我要启动一个页面并拿结果”这件事抽象成一个注册好的Launcher。调用方在使用前先声明一个待启动的请求之后无论何时何地只需要launch一下回调里就能拿到结果不再需要到处写requestCode判断。class MainActivity : AppCompatActivity() { private val pickCityLauncher registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result - if (result.resultCode RESULT_OK) { val city result.data?.getStringExtra(city) Log.d(通信示例, 选择的城市: $city) } } fun showPickCity() { val intent Intent(this, PickCityActivity::class.java) pickCityLauncher.launch(intent) } }这里特别想强调ActivityResultContracts的几个内置契约。除了最常用的StartActivityForResult还有TakePicture、GetContent、RequestPermission等一堆现成的契约类。比如在Activity里申请权限就可以简化成private val requestPermissionLauncher registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (granted) { // 拿到了权限 } }这个写法比老的requestPermissions onRequestPermissionsResult回调清爽太多也天然规避了运行时权限和页面生命周期纠缠的问题。新的Activity Result API还有个巨大优势Launcher的注册是在组件创建阶段的不会丢失回调即使Activity因配置变更重建了结果也能正确回调到对应Launcher上。3.3 setResult传回的细节与常见误区聊完接收端再看发送端。B页面返回结果时标准流程是先在业务逻辑里调用setResult设置结果码和数据再调用finish// 在PickCityActivity中 fun confirm(city: String) { val resultIntent Intent() resultIntent.putExtra(city, city) setResult(RESULT_OK, resultIntent) finish() }这里有个非常典型的误区有些同学习惯先finish再setResult结果是调用者永远拿不到数据。原因很简单setResult在调用时会把结果数据暂存在当前Activity然后等待系统在activity真正onStop之后把结果通过Binder传回给启动方。如果先finish这个Activity就已经开始销毁流程结果数据可能已经被清掉再调setResult就相当于对着空气喊话。还有一个细节与生命周期相关默认情况下按返回键退出Activity时结果码是RESULT_CANCELEDdata是null。如果调用方在onActivityResult回调里不判断resultCode直接getStringExtra就会拿到null。我见过不少线上崩溃就是在这里发生的。所以接收端一定要先检查resultCode再检查data是否为空双保险。4. 跨Activity数据共享的进阶思路当项目做到一定规模页与页之间共享的数据不再只是简单参数还有可能需要跨页面同步状态比如即时刷新列表、实时更新购物车角标这时候单纯靠Intent传值就有点力不从心了。4.1 ViewModel同一个Activity内部通信的利器ViewModel的生命周期天然跟Activity绑定旋转屏幕不会销毁Activity还在数据就在。如果是在同一个Activity内部的多个Fragment之间共享数据ViewModel是当前官方最推荐的方式class SharedViewModel : ViewModel() { val selectedItem MutableLiveDataItem() fun select(item: Item) { selectedItem.value item } } // 在Fragment里获取同一个ViewModel实例 val model ViewModelProvider(requireActivity())[SharedViewModel::class.java]这里的关键是使用requireActivity()而不是this作为ViewModelStoreOwner这样两个Fragment拿到的是同一个Activity作用域下的同一个ViewModel实例数据自然就共享了。再配合LiveData或StateFlow一个Fragment更新数据另一个Fragment自动收到通知并刷新UI。但要注意ViewModel不能跨Activity。你不可能在Activity A里拿到Activity B的ViewModel。如果确实需要两个独立Activity共享一份数据那就要用下面的手段。4.2 单例与Application全局数据该不该这么用最常见的做法是把数据放到一个单例类里。比如全局登录用户信息object UserSession { var userId: String? null var nickname: String? null }Activity A登录成功写入Activity B直接读取简单直观。但这么做有两个隐患。一个是内存泄漏如果这个单例持有的是Activity、View、Context等强引用而Activity已经销毁但单例还活着那这个Activity就无法被回收内存越积越高。另一个是状态不可控用户退出登录后如果忘记清空或者清空的时机不对另一个页面可能读到脏数据。如果要做全局共享我建议遵循这么几条铁律只放“跟App同生命周期”的数据比如设备ID、App版本、启动时间。用户信息等业务数据尽量放在可持久化的存储里DataStore或数据库不要长期留在内存中。任何单例都不要直接持有Activity、View、Dialog等UI相关对象真要持有Context也只能持有ApplicationContext。4.3 事件总线与更轻量的LiveDataBus当数据变化需要触发多个页面进行联动比如登录状态变了要刷新首页、购物车数量变了要更新多个页面的角标用Intent传值就很不现实。事件总线在这种场景下是一种解法。EventBus是老牌方案通过发布/订阅模式解耦事件发送和接收。但它在Android上的生命周期管理是个老大难如果订阅者忘了取消注册或者注册期间页面已经销毁就很容易出现内存泄漏或空指针。近年来越来越多项目转向LiveDataBus写法更轻量而且天然感知生命周期object LiveDataBus { private val bus mutableMapOfString, MutableLiveDataAny() fun with(key: String): MutableLiveDataAny { return bus.getOrPut(key) { MutableLiveData() } } } // 发送 LiveDataBus.with(login_success).value user_logged_in // 接收在某个Activity或Fragment中 LiveDataBus.with(login_success).observe(this) { event - // 收到登录成功的通知 }这个方案在中小型项目里能很快解决多页面刷新问题但LiveDataBus也有自己的坑LiveData只有在setValue时通知观察者如果给同一个key反复set同一个值观察者可能收不到第二个事件因为LiveData会做粘性去重而且多个页面同时观察同一个key事件无法做到只发给其中某一个页面。所以在大型项目里事件总线更适合做成统一消息中心带事件ID、去重和线程切换否则排查问题会非常痛苦。5. 实战中会踩到的坑与排查方法这一章是最值钱的章节。前四章讲了很多“怎么做”这一章讲讲我在真实的项目开发中被这些问题反复折磨后总结出的经验。5.1 Bundle大小限制与TransactionTooLargeExceptionAndroid的Intent数据最终需要通过Binder跨进程传输而Binder缓冲区有大小限制不同系统版本不太一样大约在1MB左右。当数据量超过这个上限系统会直接抛出TransactionTooLargeException在部分机型上还会直接导致App崩溃。很多人以为只有大量位图才会触发这个异常但其实字符串、列表对象在极端情况下也会爆。我见过一个真实案例业务方在Intent里塞了一个几万条数据的JSON字符串结果在国产某机型上频繁崩溃还定位不到原因。解决方案有两种如果数据是列表结构建议只传一个id或一批id目标页面自己根据id重新查询数据。如果是Bitmap等大对象把图片存到临时文件或缓存目录通过Uri或路径传过去。有同学会说“我传个几百K的对象没事啊”那是因为离1MB还很远但项目一旦大了、机型多了边界情况就会浮出来。所以写代码时就要养成“Intent越轻越好”的习惯。5.2 在Android 7以上用FileProvider跨页面传文件URIAndroid N开始系统明令禁止在Intent中暴露file://类型的URI否则会直接抛FileUriExposedException。这是因为直接暴露本地路径等于把设备的文件系统开放给其他应用存在安全风险。正确做法是使用FileProvider生成一个content://类型的URI。因为content://类型的URI带有一个授权的读取权限你还需要在Intent里临时授权给目标应用比较典型的写法是val file File(filesDir, share.pdf) val uri FileProvider.getUriForFile(this, $packageName.fileprovider, file) val shareIntent Intent(Intent.ACTION_SEND) shareIntent.type application/pdf shareIntent.putExtra(Intent.EXTRA_STREAM, uri) shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)如果你的目标Activity是同一个App内部的页面其实还有个更简单的思路直接把文件放进自己App的files目录或cache目录然后把文件和路径传过去就好完全不需要走FileProvider那套复杂的授权。只有当你需要把文件分享给别的App时才需要FileProvider。日常排查中如果遇到“uri类型不对”或“目标Activity读不了文件”第一反应就是检查Intent里到底传的是file://还是content://以及有没有加FLAG_GRANT_READ_URI_PERMISSION。这两个点几乎是所有URI共享问题的大盘。5.3 进程被杀之后数据怎么就丢了Activity在后台被系统回收是Android内存管理机制的一部分。比如用户打开A页面把App切到后台语法上已经完全不可见这时系统内存吃紧就可能把A所在的进程清掉。当用户再切回来系统会重建Activity如果你只在onCreate里用intent.getStringExtra取数据这时intent可能还在但数据没了或者Activity是直接从savedInstanceState恢复的那么onCreate里的intent处理逻辑根本不会走。正确姿势是在保存状态时把关键数据写进Bundleoverride fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putLong(news_id, currentNewsId) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) currentNewsId savedInstanceState.getLong(news_id, -1L) }但注意onSaveInstanceState不是万能的。它只在系统销毁前让你有机会保存UI状态不能用来保存大数据。如果你要保存的是一个几万条的数据列表老老实实存数据库或本地文件不要想把数据塞进outState否则一样会触发文件描述符耗尽或Binder超限。另外如果你的业务对数据持久性要求很高比如草稿箱、购物车不要单纯依赖Activity的状态恢复机制应该在数据变化的那一刻就同步写进持久化存储里Activity恢复只是“最后一层兜底”。5.4 生命周期和通信时序的纠缠Activity通信里最容易出bug的就是生命周期时序。比如A页面通过startActivity跳到BB在onCreate里立刻setResult并finish代码逻辑看起来没错但A接收回调时A自己的onActivityResult是在B销毁后才回调吗其实是onActivityResult会在A的onStart之后、onResume之前回调而A的onNewIntent又是另一条路径。理解这些时序的一个核心概念Activity的启动和销毁是一套完整的状态机。你把通信动作和生命周期解耦得太松散就会陷入“回调还没到页面已经销毁”或“页面已经重建回调才姗姗来迟”的尴尬境地。我在实际项目中最常用的规避方式是在需要回调结果的页面里把核心回调处理逻辑抽到一个与生命周期绑定的LifecycleObserver中。这样系统回调触发时无论Activity当前处于onStop、onDestroy还是快速重建状态都能按预期执行。Activity Result API其实内部就是用了这套机制所以新项目在通信时序上出错的概率比老项目低很多。最后再分享一个小经验如果你是团队里负责基础架构的建议给项目统一封装一个RouterManager或者ResultHelper把页面跳转参数、结果码定义、Uri解析全部收拢到一个类里。这样后续不管是换组件化、换Jetpack Navigation还是接DeepLink你只需要改这一个类页面里的业务代码几乎不用动。Activity通信这件事看起来零散但一旦在早期做好约束后期维护真的能省掉不少无谓的时间和精力。
返回列表