ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android跨应用Activity跳转后返回原App:从原理到避坑的完整指南

Android跨应用Activity跳转后返回原App:从原理到避坑的完整指南

调用其他应用的activity结束后回到调用app,这句话几乎是我这几年在技术交流里最常看到的需求描述,也是最容易被低估的一句话。很多人以为它只是 startActivity 加一行 onActivityResult,但真正动手接第三方能力的时候,翻车点一个比一个隐蔽:要么回来之后回调是空,要么应用直接闪退,要么点返回之后旧页面被莫名重建。

这篇不是讲启动Activity的入门操作,而是围绕“后台调用外部页面 + 回到原应用继续业务流程”这条完整路径,把系统原理、正确写法、兜底方案、踩坑修复全部捋一遍。适合正在对接第三方登录、支付、地图跳转等外部能力的移动端开发者,也适合刚接触Activity生命周期的新人用来建立正确的内存模型。

如果你今天刚收到一个需求,说“我们应用要跳到客户那边的某页面,用户操作完要能回到我们App”,那这篇文章可以直接当实现清单用。

1. 先搞清楚Activity返回的三层机制

1.1 从一次跳转说起

假设App里有一个页面A,要打开另一个应用的业务页B。用户操作完成后,B被finish掉,A重新出现在前台,并且能拿到B回传的数据。这套链路里系统其实做了三件事。

第一层是组件解析。startActivity传入Intent后,系统要在已安装应用里找到能响应该Intent的Activity,这个过程可能是显式指定包名类名,也可能是通过Action、Scheme等隐式匹配。

第二层是任务栈归属。每个Activity都运行在某个Task里,Task可以理解成一组页面叠起来的“返回栈”。A和B如果在同一个Task中,返回时系统会自然回到A;如果B被启动到另一个Task中,返回路径就需要系统额外协调。

第三层是结果回传。通过startActivityForResult或Activity Result API发起的调用,系统会记录调用方Activity、请求码、结果回调通道。B结束时调用setResult并finish,系统把结果派发给A。

大多数“回不去”的问题,都出在第二层和第三层配合不好,而不是第一层。

1.2 启动模式是返回链路的胜负手

很多人会忽略一个核心变量:目标Activity的launchMode,以及Intent里的FLAG_ACTIVITY_NEW_TASK等Flag。

standard模式是最普通的,启动一个就新建一个实例,放在当前Task顶端。如果A用standard模式启动B,B也在同一Task里,用户返回时A和回调都在,这是最顺的情况。

singleTop模式要分场景看。如果B已经在当前Task栈顶,系统会复用栈顶实例,不会新建,这时候onNewIntent被调用,返回结果链路可能出现变数。如果B不在栈顶,行为跟standard一样。

singleTask模式最容易出事。系统会先找有没有已存在的B实例,如果找到,会把B所在Task整体拉到前台,同时清掉B之上的页面,然后调用onNewIntent。由于B可能位于另一个Task,而且不是通过常规压栈方式启动,A和B之间的返回栈关系已经乱了。很多第三方SDK为了“App内调用后返回不重复创建页面”,会在Manifest里给中转页面配singleTask,结果就是宿主App的onActivityResult黑洞,回调永远拿不到。

singleInstance比singleTask更极端,整个Task里只有B一个Activity。它不仅跨Task,而且因为任务栈独立,系统回传结果的机制在部分Android版本上直接失效。

所以我在自己的项目里有一条不成文的规矩:凡是需要跨应用调用,并且要求结束后一定要回到原App的Activity,除非万不得已,一律不要给它配singleTask或singleInstance。如果目标Activity是第三方SDK里的页面,没办法改它的启动模式,那就必须在调用侧做兜底。

2. 最容易翻车的三类调用场景与判断清单

2.1 场景一:第三方SDK的业务页

最典型的是第三方登录、支付确认、实名认证这类页面。宿主App把自己的Activity放在栈里,点击按钮后跳到外部SDK的Activity,用户在外面的页面操作完,SDK内部会调用finish把用户带回宿主。

这类场景里的坑经常不是SDK本身,而是宿主的启动模式写得太随意。有的项目为了让登录页面全局唯一,给Activity配了singleTask,然后从登录页去启动第三方页面,返回时整个任务栈被系统重排,登录页的onActivityResult收不到结果,业务卡在“正在登录中”的loading状态里。

更隐蔽的是,有些SDK会要求在Application里初始化,初始化过程中注册了一个全局的ActivityLifecycleCallbacks,对外部Activity的onPause/onResume做监听。如果你在onResume里强行做“回调补偿”,但没有考虑页面是在前台还是后台,可能会把用户还没完成的操作误判成“已结束”,造成业务状态错乱。

这种场景的判断标准很简单:这个外部页面结束之后,是不是必须回到同一个页面继续操作?如果是,那么起跳页面和被跳页面至少得保证同一任务栈关系是连贯的。处理办法是,宿主主流程页面用standard或singleTop,不要在跨应用调用链里乱用singleTask。

2.2 场景二:跨应用的通用工具类页面

现在很多App都有“打开系统相册选一张图”“跳转到地图App查看路线”这类需求。这类页面通常不是我们自己写的,而是系统或其他应用提供的Activity。

这一类场景的典型问题是:外部页面返回后,原App的Activity可能已经因为内存不足被系统杀掉。Android系统在后台回收进程是很正常的,尤其当目标应用是地图、相机这类吃内存的页面时,你的App进程很容易被LMK(Low Memory Killer)盯上。进程没了,你Activity里的一切状态都归零,等外部页面finish,系统发现调用方Activity已经不在了,不会帮你恢复。

这种情况不能靠onActivityResult救场,要靠Activity Result API的机制配合状态保存,或者干脆走深链中转方案,在用户回到App后重建整个任务栈。

2.3 启动前先做一次“能否安全返回”的判断

我习惯把一个跨应用调用需求拆成四个问题:

  • 目标Activity是否受自己控制?如果能在Manifest里改启动模式,优先改成standard。
  • 调用方Activity是否可能被系统回收?如果可能,要做好状态保存和launcher注册的持久化。
  • 是否使用了隐式Intent?用了的话,得检查Android 11及以上的包可见性配置。
  • 外部页面结束后是靠物理返回键还是靠页面内按钮?如果是物理返回,系统会自动回栈,风险小;如果是页面内按钮直接finish,也要走同一套回调逻辑,并没有更特殊。

如果一个需求在启动前就能明确判断出“当前App进程可能被杀”或“外部SDK页面启动模式不可控”,我一般会直接选择深链中转或者onResume补偿方案,而不是赌startActivityForResult能可靠回调。不要把一个稳定性需求建立在不可控组件的行为上。

3. 主流程实现:用Activity Result API把返回链路做扎实

3.1 为什么新项目不要再写startActivityForResult

老代码里startActivityForResult这个API确实能用,但它有几个历史包袱:回调方法onActivityResult的requestCode需要在多个跳转之间人工分配,容易出现重复;页面因为旋转、深色模式切换等原因重建时,requestCode和结果绑定容易丢失;进程被杀恢复现场之后,旧回调机制基本没有官方恢复方案。

Activity Result API从AndroidX Activity 1.2.0开始稳定,后来成为官方推荐的做法。它的核心是ActivityResultLauncher,注册之后与当前Activity的SavedState绑定,即使Activity重建,只要注册时机正确,系统也能在重建之后恢复这次调用的结果。说白了,它把“调用方期望一个结果”这件事做成了可恢复状态。

这里要强调一个观念:Activity Result API并不是简单地封装了一下startActivityForResult,它把回调用的Binder通道、状态保存、生命周期注销都统一管理了。跨应用Activity结束时,系统会向调用方Task发送一个结果信号,API内部通过ActivityResultRegistry把信号派发到对应的launcher回调里。理解这一点,后面排查问题会快很多。

3.2 一个可以直接抄的Kotlin示例

我以模拟项目X为例,源App是AppA,目标App是AppB,AppA有一个启动按钮,点击后用隐式Action启动AppB中的一个页面,用户在那个页面里点“返回业务”,数据传回AppA。

AppA端代码:

class MainActivity : AppCompatActivity() { // launcher必须是字段,不能在onCreate里临时创建 private val externalPageLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { val data = result.data val code = data?.getStringExtra("biz_code") ?: "" val message = data?.getStringExtra("biz_message") ?: "" // 回到业务流,刷新页面状态 showResult(code, message) } else { // resultCode为RESULT_CANCELED,说明用户在外部页面直接放弃了 showResult("canceled", "") } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 假设已经setContentView并绑定了按钮 btnLaunch.setOnClickListener { val intent = Intent("com.appb.action.BIZ_PAGE") intent.addCategory(Intent.CATEGORY_DEFAULT) intent.putExtra("request_source", "app_a") externalPageLauncher.launch(intent) } } private fun showResult(code: String, message: String) { // 更新UI,比如把结果显示在TextView上 } }

AppB端,目标Activity实现:

class BizPageActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 业务操作完成后,用户点了“完成”按钮 btnDone.setOnClickListener { val data = Intent().apply { putExtra("biz_code", "SUCCESS") putExtra("biz_message", "外部业务已完成") } // 先setResult,再finish setResult(RESULT_OK, data) finish() } } }

AppB的Manifest里需要把这个Activity暴露出来,并且声明它可以响应隐式Intent:

<activity android:name=".BizPageActivity" android:exported="true" android:launchMode="standard" android:theme="@style/AppTheme.Transparent"> <intent-filter> <action android:name="com.appb.action.BIZ_PAGE" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity>

AppA这边,因为用隐式Intent调用外部应用的页面,在Android 11及以上需要在Manifest里声明包可见性。不写这个,系统会认为当前App“看不见”目标App,launch会直接抛ActivityNotFoundException:

<queries> <intent> <action android:name="com.appb.action.BIZ_PAGE" /> <category android:name="android.intent.category.DEFAULT" /> </intent> </queries>

这段测试下来,在模拟器和真机上都能稳定回调,物理返回键和页面按钮返回两条路都走通了。

3.3 老项目迁移要注意什么

如果项目里已经有成片startActivityForResult代码,不建议一晚上全部推翻重写。我的做法是分两步。

第一步,新写的跨应用调用一律用Activity Result API,不再新增旧API调用。

第二步,老代码可以等页面有维护需求时再迁移。迁移时注意:旧的onActivityResult方法可以保留给还没改造的请求码使用,但新注册的launcher回调不会走onActivityResult,不要在两边同时处理同一个请求,否则会出现重复回执。另外,要检查所有registerForActivityResult调用是否定义在Activity或Fragment的字段初始化位置。如果写在了生命周期方法里,比如onCreate里临时创建,系统在配置变更后可能丢失launcher状态。

还有一个细节:同一个launcher不要同时启动两个不同的跨应用调用。如果需要同时调用多个外部页面,注册多个launcher,或按业务模块封装不同的launcher。这个设计能避免返回时搞不清回调属于哪一次请求。

4. 兜底方案:不依赖startActivityForResult也能回到原App

4.1 通过ResultReceiver或广播做进程内回调

有些情况下,我们能预见到外部页面不可控,但能把“业务完成标记”的数据提前放到Intent里传给对方,那么可以用ResultReceiver解决一部分问题。

思路是:启动外部页面之前,在Intent里放入一个ResultReceiver对象。目标页面哪怕不听你控制,只要它在结束时能把Intent原样返回,或者主动调用Receiver.send,你就能收到回调。即便目标页面只是把Intent转交给我们自己的中转组件,通道也能打通。

val receiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { // 外部业务结束时,这里的代码会被执行 handleExternalFinish(resultData) } } val intent = Intent("com.appb.action.BIZ_PAGE").apply { putExtra("callback_receiver", receiver) } externalPageLauncher.launch(intent)

这个方案的实际限制是:目标页面不一定愿意执行你的Receiver。第三方SDK如果自己有完整的回调通道,一般不会理会调用方塞进来的私有Receiver。所以ResultReceiver更适合用在“目标Activity是自己团队另一个应用”的场景。真正的第三方SDK还是要优先看SDK自己的回调设计。

广播方案同理,可以在自己的App里注册一个自定义Action的BroadcastReceiver,然后让外部页面在结束前发一个广播。这里有个现实问题:外部应用往你App发广播,如果Receiver是动态注册,要求你的进程还活着;如果是静态清单注册,Android 8.0以后隐式广播限制很麻烦。所以广播只适合同应用、同签名的模块间回调,或者由你自己App的底层系统组件辅助接收,不要指望外部应用替你做这件事。

4.2 用onResume补偿检测兜住不可控返回

我之前做模拟项目X时,有一个页面跳到了某个第三方认证页面,SDK返回时偶尔会丢回调。后来我加了一道保险:在启动外部页面前,把一个“等待外部返回”的标记位设为true,然后重写onResume。如果外部页面结束后,原Activity被重新带到前台,onResume一定会执行,这时检测到标记位还在置真状态,就说明回调通道没过来,或者说回调已经在之前的进程死亡里丢了,此时按“业务结束但结果未知”处理。

private var waitingExternalResult = false override fun onResume() { super.onResume() if (waitingExternalResult) { waitingExternalResult = false // 补偿刷新:向业务层查询外部操作的最新状态 refreshBusinessState() } } private fun launchExternalPage() { waitingExternalResult = true externalPageLauncher.launch(intent) }

这段逻辑本来只是兜底,后来我发现它实际上解决了很多“回调不确定”的问题。原因很简单:Activity Result API的回调在个别厂商ROM上会被延迟,甚至丢到另一个窗口生命周期里,但onResume作为最基本的生命周期方法,能确保页面真正回到前台时触发。

注意,不要在onPause里清理标记。onPause在启动外部页面之前也会触发,如果把标记清掉,回来时就检测不到了。这个标记真正的生命周期应该从“启动外部页面”开始,到“onResume或成功回调”结束。

4.3 用深链中转完成栈归位

如果外部页面不在你的Task里,而且你无法修改它的行为,那还有一个经典方案:深链中转。

操作分三步。第一步,在你的App里注册一个非常轻量的中转Activity,专门处理来自外部页面的跳回。第二步,启动外部页面时,把中转Activity的深链地址塞进Intent参数,让外部页面在结束前跳到这个地址。第三步,中转Activity收到后,先把自己所在的任务栈收拢,再finish自己,回到你的主业务页面。

中转Activity要设计成透明主题,否则用户会在外部页面和业务页面之间看到一闪而过的白屏。清单声明类似:

<activity android:name=".ResumeTransitActivity" android:exported="true" android:launchMode="singleTask" android:taskAffinity="" android:theme="@style/Theme.Transparent"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="myapp" android:host="resume" /> </intent-filter> </activity>

注意这里解释一下:中转Activity用singleTask是为了保证外部页面结束跳转时不会重复创建多个中转实例。它所在的Task会被系统复用和归拢,然后在onCreate或onNewIntent里立即finish,把控制权交还给启动它之前的前台任务。如果发现用户原来的业务页面Task还在后台,可以在finish前调用moveTaskToFront,原页面就能完整地回来了。

深链中转的优势是可靠,几乎不受启动模式影响。劣势是外部页面得配合,知道要跳转你的深链地址。如果对方是封闭SDK,根本不给你放链接的位置,这条路也走不通。但不管怎样,它是我处理“外部页面主动结束但原栈不可信”时的最后一张底牌。

5. 常见问题排查与避坑清单

5.1 高频问题速查表

现象常见原因处理方式
onActivityResult一直不回调目标Activity设置了singleTask或singleInstance;源Activity已销毁改launchMode为standard;换成Activity Result API并保证launcher是字段注册;加onResume补偿
result.data为nullsetResult时没传递Intent,或者finish在setResult之前执行先构造Intent数据,再setResult,最后finish
结果回调重复触发旧API和Activity Result API混用,同一个请求被两套机制同时处理迁移时按请求区分,不要共用同一业务路径
Android 11以上启动外部页面崩溃缺少queries包可见性声明在Manifest里补 声明
外部页面返回后原Activity黑屏启动时使用了FLAG_ACTIVITY_NEW_TASK,导致新Task覆盖了原Task检查Intent flags,跨应用调用时不要随意组合NEW_TASK和CLEAR_TOP
分屏模式下回调延迟或丢失多窗口生命周期变化导致回调派发时机异常在onResume里做二次确认,不要依赖单一回调

这张表是我每次接到“外部页面回不来”问题时先对照的清单。很多时候排查一圈,问题不是出在复杂机制上,而是最简单的那几个点。

5.2 我踩过的五个坑

第一个坑是给“被启动页面”配singleTask。当时是为了防止第三方Activity重复创建,结果代价是所有调用方都收不到回调。定位过程很痛苦,最后用adb shell dumpsys activity activities看任务栈结构,发现外部页面跟源页面完全在两个Task里,结果信号根本派发不回来。从那以后我再也不随便给跨应用调用目标配特殊启动模式。

第二个坑是在非Activity上下文里调用startActivity,比如从Service或Application启动外部页面。系统要求这种情况下必须带FLAG_ACTIVITY_NEW_TASK,否则直接崩溃。但带上这个Flag之后,外部页面又不会回到原来的Task,导致返回链路断裂。我的经验是:跨应用跳转尽量从Activity上下文发起,并且通过Activity Result API启动,让系统自己处理Task归属。如果确实必须从Service启动,那就自己维护好后续的栈恢复逻辑,不要指望系统自动带你回原页面。

第三个坑是registerForActivityResult的位置。刚开始用Activity Result API时,我图省事把launcher定义在onCreate里,想着反正调用前肯定会走到onCreate。结果屏幕旋转一次,Activity重建,launcher状态没了,回调要么不触发,要么事件被重复注册。正确做法是作为Activity的字段在初始化时注册,让ActivityResultRegistry在实例创建时就登记好,这样配置变更后可以靠SavedState恢复。

第四个坑是隐式Intent的包可见性。Android 11发布后,我有一段旧代码从自己的App跳转到另一个已安装的工具App,一直没有问题,后来在某台Android 12设备上突然抛ActivityNotFoundException。系统日志里明明有目标App,但就是解析不到Intent。原因就是没有在Manifest里加queries声明。Android系统现在要求应用声明自己能看到哪些包,不声明就算目标应用装着也当不存在。这个问题出现得很隐蔽,因为大多数机器上旧App依然能解析,报错只发生在特定版本上。

第五个坑是进程被系统回收后的“假死”问题。外部页面把宿主App进程挤掉后,外部页面返回时,系统会重新拉起宿主App,但恢复的是任务栈里的Activity,Activity Result API回调里的闭包对象已经不存在了,你拿不到当时的上下文引用。这时候再用launcher回调里的对象去更新UI,基本都会空指针。解决方法是不要在launcher回调里长期持有Activity强引用,尽量通过ViewModel等可恢复状态间接更新,并且在onCreate里判断SavedInstanceState是否非空来决定是恢复现场还是重新初始化。

5.3 一个重要提醒:返回键处理

目标页面如果允许用户按物理返回键退出,Android默认行为是finish掉目标Activity,并把结果设为RESULT_CANCELED。很多启动模式异常的场景里,返回键路径反而能正常回调,因为系统在处理返回栈时会把Task关系理清楚。

但如果你的应用在目标页面里用了自定义“关闭按钮”,而且按钮里写的不是finish而是moveTaskToBack,那就不会触发结果回调,因为Activity没有被结束,只是整个Task退到后台。外部页面再回来时,你的业务会误以为流程被彻底中断了。这里要注意:目标页面退出时一定要真正finish,而不是简单地把Task移到后台。这个动作对跨应用返回链路是致命的。

6. 最后分享一个让我印象最深的排查过程

有一次模拟项目X里,外部认证页面返回之后,原页面偶尔会显示一次黑屏,然后才恢复内容。当时怎么也复现不出稳定规律,后来才发现是目标Activity在Manifest里配了singleTask,而且启动Intent里又加了一个FLAG_ACTIVITY_NEW_TASK,两个设置叠加导致Task重排,原页面在黑屏期里重新走了生命周期,界面闪烁。把目标页面的launchMode改成standard并把Intent里的多余Flag去掉之后,问题彻底消失。

之后我给自己定了一条工作习惯:所有跨应用调用的Activity,启动前先用adb shell dumpsys activity activities确认一下当前任务栈结构。如果发现启动后目标页面不在预期位置,那就不必继续等回调了,赶紧修启动参数。这个命令比反复打日志高效得多。

再补一个实用小技巧。如果外部页面结束后你想让原页面自动刷新某个数据,不要只在回调里做刷新,也在onResume里做一次轻量检查。回调负责精确判断结果,onResume负责兜底保证页面状态正确。两条路径都跑过之后,跨应用返回这个事才算真正做稳了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进