Android 状态保持:进程被杀、旋屏与后台任务的存活术

进程必死、状态可活:把"让 App 别死"换成"让状态可重建",这一类崩溃与丢失就解了。

你写下的 Activity、Fragment、定时器、下载任务,在 Android 眼里都不是"你的资产",而是"系统随时可以回收的内存"。用户切到后台接个电话,你的进程可能被干掉;屏幕一转,Activity 被拆了重建;App 被划掉再打开,内存里的任务列表归零。这些问题单个看像玄学,拼到一起却指向同一个判断:"存活"不是让进程别死(做不到),而是让状态在进程死掉前后可重建。

这篇文章把两条尺度并到一条主线上讲。第一条是页面级 的状态存活------Fragment 那几个让人头秃的崩溃和静默丢失,本质是重建时状态没接住;第二条是任务级 的状态存活------前台服务下载,靠"可序列化的任务模型 + 前台服务"让一个跑了半小时的任务在 App 被杀后还能接着下。两件事尺度不同,解法不同,但根子是同一个:你得主动决定什么该活下来,而不是指望进程长命百岁。

一、统一命题:状态比进程命长

先建立一个最基本的认知。Android 的进程管理不是"尽量不杀你",而是"内存不够就杀,杀完再按需重建"。系统销毁进程时不会跟你商量,也不会保留任何内存对象;它唯一会替你做的,是按规则把声明过要保存的状态序列化下来,等你切回来再反序列化、重建界面。

这就引出状态保持的两个尺度:

  • 页面级状态 :绑定在某个 Activity / Fragment 上的界面状态。触发重建的往往是旋屏、语言切换、进程被杀恢复。尺度小、频率高,框架本身已经替你想了一部分(比如 onSaveInstanceState),但"边角料"很多,坑全在这。
  • 任务级状态:不依附于某一页、希望跨进程生死长期存在的状态,比如一个正在下载几百 MB 离线包的任务。尺度大、生命周期长,框架基本不管你,全靠自己设计持久化和保活机制。

两者不是并列的两套技巧,而是同一句话的两种粒度:进程会死,所以把"活下来"的需求从进程身上转移到"状态"身上 。页面级靠 SavedState 类机制让框架替你回填,任务级靠"可序列化的任务模型 + 前台服务"让你自己重建。

下面这张图把两条路径放进同一个"进程被杀 → 重建"的骨架里,后面所有坑和机制都是它的注脚。

flowchart TD A["进程运行中 页面与任务都在内存"] --> B{"系统内存不足?"} B -->|"是 回收进程"| C["进程被杀 内存全清"] B -->|"否 正常运行"| A C --> D["用户切回 系统尝试重建"] D --> E{"状态是否已主动持久化?"} E -->|"页面级 SavedState 机制"| F["Fragment/Activity 重建 状态回填"] E -->|"任务级 序列化任务模型"| G["前台服务重启 任务恢复续跑"] F --> H["用户无感 界面复活"] G --> H

落到手段上,两尺度的差异非常清晰,先给一张总览对比,后面每一节都是对其中一格的展开。

维度 页面级状态(Fragment / Activity) 任务级状态(下载任务等)
生命周期尺度 单页 / 单 Activity 重建 跨进程生死、跨 App 启停
存活机制 SavedState、实例保留 Fragment 前台服务 + 可序列化任务模型
持久化位置 内存 Bundle,由框架自动回填 应用私有目录文件(如 XML)
典型触发场景 旋屏、进程被杀后恢复 退后台、App 被划掉重启
丢失代价 界面卡死、崩溃、数据静默丢失 任务重头下、浪费流量与时间
代表 API onSaveInstanceState / commitAllowingStateLoss startForeground / Range / 序列化器

记住一句话:页面级是把状态交给框架回填,任务级是把状态交给文件重生。下面先看框架这块最容易翻车的四个坑。

二、页面级状态存活(上):重建崩溃与提交边界

2.1 进程被杀后恢复崩溃:ClassLoader 之谜

这是最经典也最隐蔽的一个。场景:应用在后台被系统回收,用户切回来,系统帮你恢复 Fragment 的状态------然后崩了,崩溃点是一个 ClassCastException,但崩溃的类看起来毫无问题,你翻遍自己的代码也找不到这个转换在哪。

根因在于 Bundle 的 ClassLoader 。Fragment 的状态被序列化保存时,内部的子 Bundle 会记录"我是由哪个 ClassLoader 加载的"。进程被杀后重启,框架用一个新的 ClassLoader 去反序列化这个状态;但子 Bundle 里记录的 ClassLoader 还是旧的、已经随旧进程失效的那个 。于是框架反序列化时把对象误判成错误的类型,抛出 ClassCastException。注意,问题不在你的数据,而在"反序列化用的类和当初序列化时的类不是同一个类加载器眼中的同一个类"。

解法很朴素:在 Fragment 恢复状态前,把整个 Bundle 以及它里面嵌套的所有子 Bundle 的 ClassLoader 重置为当前进程的 ClassLoader:

kotlin 复制代码
fun fixClassLoader(bundle: Bundle?) {
    bundle ?: return
    // 修复嵌套子 Bundle
    bundle.getBundle("saved_state_key")?.let { child ->
        child.keySet()?.forEach { key ->
            (child.get(key) as? Bundle)?.classLoader = Bundle::class.java.classLoader
        }
    }
    // 修复顶层 Bundle
    bundle.classLoader = Bundle::class.java.classLoader
}

在 Activity 的 onCreate 里、Fragment 恢复状态之前调用一下,这个崩溃就消失了。看似一行代码,背后是对"序列化状态生命周期"的完整理解:状态能跨进程活下来,靠的是序列化与反序列化,而反序列化要想正确,类加载器必须对齐到当前进程。这里有个边界值得说------saved_state_key 是子 Bundle 的存放键,真实项目里它往往被包在多层嵌套里,所以代码里是"先修子 Bundle,再修顶层 Bundle"的顺序,漏掉任一层都可能让某个深层字段继续用失效的 ClassLoader。

2.2 onSaveInstanceState 之后的提交崩溃

Fragment 的事务(commit)本应在 onResume 到 onPause 之间进行。如果你在 onSaveInstanceState 之后还调用 commit(),框架会抛 Can not perform this action after onSaveInstanceState------因为这时界面的保存快照已经生成,再改就来不及并进那个快照了。

这个坑多发生在异步回调 里:网络请求返回、Handler 延迟触发时,页面可能已经被切到后台并保存了状态,此时再 commit 就崩。问题的本质是"你以为页面还活着,其实框架已经给它拍完照准备收工了"。

成熟的做法是用"允许状态丢失"的版本:

kotlin 复制代码
// 允许在状态已保存后提交,宁可少恢复一次,也不崩溃
fragmentManager.beginTransaction()
    .add(fragment, tag)
    .commitAllowingStateLoss()

// 对话框同理
dialogFragment.showNow(fragmentManager, tag) // 同步立即显示
dialogFragment.dismissAllowingStateLoss()   // 允许状态丢失地关闭

代价是:如果页面状态已经保存,这次事务不会被记录,进程被杀后不会恢复。但对绝大多数"弹个提示、关个对话框"的场景,丢失一次恢复远好过崩溃。下面这张时序图把"快照已生成"这个边界点标出来,你一眼就能看出为什么异步回调里最容易踩:

sequenceDiagram participant A as Activity participant F as Fragment participant Sys as 系统/框架 A->>A: onPause A->>F: onSaveInstanceState 快照生成 Note over A,F: 此后提交事务将崩溃 A->>A: onStop Sys-->>F: 异步回调返回 触发 commit F->>Sys: commit Sys-->>F: Can not perform this action after onSaveInstanceState Note over F: 改用 commitAllowingStateLoss 才安全

这里有个工程上的判断点:不是所有提交都该无脑 commitAllowingStateLoss。如果是"用户刚点的一个关键导航",丢了这次提交会让界面状态错乱,那也许该用 showNow 这种同步立即显示、或干脆缓存意图等 onResume 后再提交。只有当"这次 UI 变更丢了也不影响正确性"时,才用允许丢失的版本换不崩溃。

三、页面级状态存活(下):旋屏与对话框的隐蔽坑

3.1 旋屏丢定时器:实例保留 Fragment

页面开了个定时器,一秒一跳地刷新倒计时。屏幕一转,Activity 重建,定时器连同它的 Handler 一起没了。更麻烦的是,如果 Handler 里持有旧 Activity 引用,还容易泄漏------旧 Activity 本该被回收,却被定时器拽着不放。

思路是:把后台任务 / 定时器放进一个"保活"的 Fragment,这个 Fragment 设置"实例保留",让它不随 Activity 重建而重建。旋转时 Fragment 实例存活,任务继续跑,只是把回调目标换成新的 Activity。

kotlin 复制代码
class RetainedFragment : Fragment() {
    private val handler by lazy { Handler(Looper.getMainLooper()) }
    private var runnable: Runnable? = null

    fun startTicker(onTick: (Long) -> Unit) {
        runnable = object : Runnable {
            override fun run() {
                onTick(System.currentTimeMillis())
                handler.postDelayed(this, 1000)
            }
        }.also { handler.post(it) }
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        // 这里通过当前 Activity 重新挂接 UI
    }
}

关键细节:Handler 持有的是弱引用 还是强引用?如果保活 Fragment 里的 Handler 强持有旧 Activity,照样泄漏。所以正确姿势是 Handler 用弱引用指向 Activity,或者干脆只在 onActivityCreated 时通过当前宿主拿引用,不长期缓存。换句话说,"实例保留"解决了"任务随页面死掉"的问题,却把"如何不持有旧页面"的新问题甩给你------活下来的 Fragment 必须时刻意识到自己的宿主可能已经换了一茬。

3.2 对话框第二次弹不出来:被遗忘的 mDismissed

最后一个坑非常隐蔽。一个 DialogFragment,第一次 show() 后 dismiss(),第二次再 show()------结果没反应,连崩溃都没有,就是安静地什么也不做。

原因在于父类里有个私有的 mDismissed 标记,dismiss() 之后它被置为 true。如果不重建实例,第二次 show() 时框架检查到这个标记直接短路,什么都不做。这不是你代码写错,而是框架内部状态机的默认行为:它假设 DialogFragment 是一次性消费的。

标准解法是每次新建实例。但在某些框架内部确实存在复用实例的场景,于是有人用反射把这个私有标记重置掉:

kotlin 复制代码
override fun show(manager: FragmentManager, tag: String?) {
    try {
        // 找到父类里控制"已关闭"的私有字段,重置它
        val dismissedField = parentClass.getDeclaredField("mDismissed")
        dismissedField.isAccessible = true
        dismissedField.setBoolean(this, false)
    } catch (_: NoSuchFieldException) {
        // 反射失败就退回正常流程
    }
    super.show(manager, tag)
}

这种反射 hack 依赖库内部实现,不是长久之计------哪天父类把字段改名,这段代码就静默失效,所以 catch (NoSuchFieldException) 必须退回正常流程,不能让它抛出去。但作为"框架不给复用能力时的最后一招",它在真实工程里确实很常用。如果你有条件,优先还是"每次 newInstance() 一个全新实例",把状态显式传进去,干净又不怕版本变更。

把四个坑收进一张表,方便你对照排查。它们单个看各不相干,本质全是 Fragment 的"状态保存 / 恢复机制有边角料":

现象 根因 解法
进程被杀恢复崩溃 ClassCastException 子 Bundle 记录的 ClassLoader 已失效 恢复前重置整个 Bundle 及嵌套子 Bundle 的 classLoader
onSaveInstanceState 之后 show/dismiss 崩溃 快照已生成再 commit 来不及 改用 commitAllowingStateLoss / showNow / dismissAllowingStateLoss
旋屏丢定时器 / 异步任务 Activity 重建,Handler 与任务随实例销毁 用实例保留 Fragment 承载,Handler 弱引用宿主
对话框第二次弹不出 父类私有 mDismissed 标记未重置,show 短路 每次 newInstance,或反射重置 mDismissed

四、任务级状态存活:前台服务凭什么不被杀

页面级的坑讲完了,尺度拉大一级。行业应用里经常要下载大数据------离线地图包、固件、测量数据。这些下载有个共同特点:又大又慢,可能跑几十分钟甚至几小时。用户不可能一直盯着屏幕,他切到后台、锁屏、甚至把 App 划掉,下载不能就此中断。

这带来两个硬需求:

  1. 退到后台继续下:需要前台服务,因为系统会杀掉后台进程来回收内存。
  2. App 被杀不丢任务:需要把"正在下的任务"持久化,下次启动能恢复进度继续下。

先看第一个需求。普通后台服务很容易被系统回收,尤其 Android 8.0 以后对后台服务限制极严------后台应用能做的事情被砍到几乎不剩。前台服务则因为有一个常驻通知栏的通知,系统认为用户"看得见"它,所以基本不杀。

启用前台服务,关键是启动时绑定一个通知,并在适当的时候调用对应 API:

kotlin 复制代码
class DownloadService : Service() {

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // 启动前台服务,绑定常驻通知
        startForeground(NOTIFICATION_ID, buildNotification())
        // 处理下载任务...
        return START_STICKY // 被系统杀后尝试用原 intent 重启
    }

    private fun buildNotification(): Notification {
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("下载中")
            .setContentText("正在下载离线包...")
            .setProgress(100, progress, false) // 可显示进度
            .build()
    }
}

要点:

  • 必须建一个通知渠道 (Android 8.0 强制),通知要显示在状态栏。CHANNEL_ID 对不上或渠道没创建,前台服务会直接抛异常起不来。
  • START_STICKY:服务被系统杀掉后,会尽量用原 Intent 重新拉起,配合下面的恢复逻辑就能续传。注意 START_STICKY 不保证 100% 重启成功(极端内存压力下系统可能彻底放弃),所以"重启后能从文件恢复任务"才是兜底,前台服务只是尽量不死,不是永生。

这里要和页面级对照着看:页面级指望框架在进程死后重建界面,任务级则指望前台服务让进程尽量别死、再配一份文件兜底。两者合起来才是"任务级状态存活"的完整答案------前台服务负责"大概率活着",序列化文件负责"真死了也能复活"。

五、任务级状态存活:可序列化任务模型与断点续传

5.1 任务模型:可持久化的数据类

下载任务要被序列化保存,所以它必须是一个能落盘的数据结构,而不是一堆散落的临时变量。把"要下什么、下到哪、下到哪一步"都放进一个数据类:

kotlin 复制代码
data class DownloadTask(
    val id: String,
    val url: String,
    val targetPath: String,
    val totalBytes: Long = 0L,
    val downloadedBytes: Long = 0L,
    val status: Status = Status.PENDING  // PENDING/RUNNING/PAUSED/DONE/FAILED
)

所有任务存进一个列表,每次进度变化更新对应任务的 downloadedBytes,这就是"断点续传"的种子。注意 targetPath 是落盘路径、url 是下载地址------这两个字段决定了"重启后去哪下、下到哪",缺一不可;status 决定恢复时是续跑还是丢弃。把字段含义和续传角色对上,后面序列化才有意义:

字段 / 状态 含义 续传中的作用
id 任务唯一标识 恢复后重新入队时做匹配
url / targetPath 下载地址与落盘路径 重启后定位资源与目标文件
totalBytes / downloadedBytes 总量与已下字节 计算进度,并作为 Range 起点
status PENDING/RUNNING/PAUSED/DONE/FAILED 决定恢复时是续跑还是丢弃
download_tasks.xml 持久化文件 进程死后再生的种子

5.2 序列化:App 被杀后任务还在

序列化要解决的问题是:进程被系统杀掉,内存里的任务列表全没了 ,下次启动 downloadedBytes 是 0,只能从头下。

解法是把任务列表持久化到文件。序列化格式可以选 XML(可读性好、便于排查),存到应用私有目录。关键操作是"任务列表 <-> 文件"的往返:

kotlin 复制代码
// 保存
fun persist(tasks: List<DownloadTask>) {
    val xml = serializer.toXml(tasks)
    File(requireContext().filesDir, "download_tasks.xml").writeText(xml)
}

// 恢复
fun load(): List<DownloadTask> {
    val file = File(requireContext().filesDir, "download_tasks.xml")
    if (!file.exists()) return emptyList()
    return try {
        serializer.fromXml(file.readText())
    } catch (e: Exception) {
        emptyList() // 文件损坏就放弃,宁可重新下
    }
}

启动时先 load() 恢复任务列表,把状态为"未完成"的任务重新加回下载队列。因为 downloadedBytes 被保存下来了,重新开始时可从该偏移继续(如果服务端支持 Range 请求)。这里有个工程取舍:load() 时如果文件损坏,代码选择返回空列表"宁可重新下"------因为下载任务即便重头来过,代价也只是流量和时间,远比"用一份坏数据假装恢复成功"安全。这也是任务级和页面级的一个差异:页面级状态坏了会让界面崩,任务级状态坏了却可以坦然丢弃重来。

5.3 断点续传:从已下载字节继续

续传的关键是"接着上次的字节继续下",而不是重新下。HTTP 的 Range 头正好干这个:

kotlin 复制代码
fun resume(task: DownloadTask) {
    val conn = URL(task.url).openConnection() as HttpURLConnection
    if (task.downloadedBytes > 0) {
        // 从已下载位置继续
        conn.setRequestProperty("Range", "bytes=${task.downloadedBytes}-")
    }
    conn.connect()

    // 打开输出流,以追加模式写入,不覆盖已有内容
    val input = conn.inputStream
    val output = File(task.targetPath).outputStream().apply {
        (this as FileOutputStream).channel.position(task.downloadedBytes)
    }
    // 流式拷贝,边下边更新进度并持久化
    val buffer = ByteArray(8192)
    while (true) {
        val read = input.read(buffer)
        if (read == -1) break
        output.write(buffer, 0, read)
        task.downloadedBytes += read
        persistOne(task) // 每次有进度就落盘一次(可节流)
    }
}

细节:

  • Range: bytes=N- 让服务端从第 N 字节开始返回,配合文件追加写入(FileOutputStream 的 channel.position 跳到已下载偏移),就实现了续传。若服务端不支持 Range,返回的是完整文件,那 position 跳转会写错位置------所以续传前最好先探一下响应头 Accept-Ranges / Content-Range,不支持就从头下。
  • 进度更新和序列化可以节流 (比如每 1% 或每 1 秒落盘一次),避免频繁写盘拖慢下载。persistOne(task) 就是"只更新这一个任务再落盘",否则每收 8 KB 就重写整个 XML 文件,I/O 会成为瓶颈。

把下载任务的生命周期画成状态机,能更清楚 status 字段在恢复时怎么被使用------哪个状态该续跑、哪个该丢弃,一目了然:

stateDiagram-v2 [*] --> PENDING PENDING --> RUNNING : 入队开始 RUNNING --> PAUSED : 用户暂停 PAUSED --> RUNNING : 继续 RUNNING --> DONE : 下载完成 RUNNING --> FAILED : 网络错误 FAILED --> RUNNING : 重试 DONE --> [*] PAUSED --> [*] : 取消

而"文件恢复 → 续传 → 落盘"这条实际跑起来的链路,是下面这样一个闭环;它会一直转,直到 H 判定完成,才把状态置 DONE 跳出:

flowchart LR A[&#34;load 从文件恢复任务列表&#34;] --> B{&#34;有未完成任务?&#34;} B -->|&#34;是&#34;| C[&#34;加入下载队列&#34;] B -->|&#34;否&#34;| D[&#34;空闲&#34;] C --> E[&#34;发起 Range 请求&#34;] E --> F[&#34;追加写入 已下载字节&#34;] F --> G[&#34;节流 persistOne 落盘&#34;] G --> H{&#34;完成?&#34;} H -->|&#34;否&#34;| E H -->|&#34;是&#34;| I[&#34;状态置 DONE&#34;]

5.4 分层:下载逻辑与 UI 解耦

下载模块通常按层次组织:下载引擎(干活)、任务管理(队列 + 持久化)、界面层(通知栏、进度回调)。用接口把"下载引擎"和"界面回调"解耦,UI 只关心进度和结果:

kotlin 复制代码
// 下载引擎只向外部暴露进度回调
interface DownloadCallback {
    fun onProgress(taskId: String, downloaded: Long, total: Long)
    fun onCompleted(taskId: String)
    fun onFailed(taskId: String, reason: String)
}

界面(或通知栏)实现这个回调去更新 UI;下载引擎完全不知道 UI 长什么样,只管下、只管报进度。这套分层让"通知栏展示""页面进度条""后台静默下载"都能复用同一个引擎------这正好呼应了任务级状态存活的核心诉求:任务不属于任何一页,所以它必须独立于 UI 存在,UI 只是它的观察者之一。前台服务持有引擎、引擎通过回调通知 UI,进程死了由文件兜底,UI 重建了再重新挂上回调,整条链路严丝合缝。

六、小结

  • 状态保持的 unified 判断:"存活"是让状态可重建,不是让进程不死;进程必死,主动决定什么该活下来才是正解。
  • 页面级靠 SavedState 类机制:重建崩溃根因是 Bundle 记录的 ClassLoader 失效,恢复前重置即可;onSaveInstanceState 之后别用普通 commit(),改用 commitAllowingStateLoss / showNow / dismissAllowingStateLoss 换"不崩溃"。
  • 跨旋屏保留定时器用实例保留的 Fragment 承载,但 Handler 必须弱引用宿主,否则照样泄漏;复用对话框第二次弹不出是父类私有 mDismissed 在作祟,优先每次 newInstance。
  • 任务级靠前台服务 + 可序列化任务模型:前台服务凭常驻通知 + START_STICKY 尽量不被杀;任务设计成可落盘的数据类,序列化到 download_tasks.xml 让 App 被杀也不丢。
  • 断点续传靠 HTTP Range 头 + 文件追加写入 ,从已下载字节继续;下载引擎用 DownloadCallback 接口与 UI 解耦,一套引擎同时服务通知栏、进度页、后台静默下载。

你现在的项目里,是页面级的坑(比如那个只在进程被杀恢复时才出现的崩溃)更折磨人,还是任务级的长耗时下载更让你头疼?你又是用 SavedState 还是自己写了一套持久化兜底?

相关推荐
九皇叔叔1 小时前
【10】SpringBoot4 MyBatisPlus 枚举类型(enum)
android
yubang32231112 小时前
Android保活sdk
android·gitee·保活
2501_916007472 小时前
苹果应用商店App Store上架费用标准及原因解析
android·ios·小程序·https·uni-app·iphone·webview
脚踏实地,坚持不懈!2 小时前
Linux 内核源码解析:从 secondary_startup_64 到 pick_eevdf 的完整调用栈分析
android·linux·arm开发
事圆则缓3 小时前
面向对象六大基本原则:从概念到 Android 实战
android
程序员-珍3 小时前
关于协程相关问题
android·安卓
wdfk_prog3 小时前
Wi-Fi Direct 教程 04:Interface 协议子系统初始化——WPA/EAPOL、WPS、DPP/NAN、GAS 与 P2P callback
android·运维·服务器·ubuntu·p2p·wps·wifi-direct
警醒与鞭策12 小时前
【无标题】
android·unity·性能优化·游戏引擎·perforce
传奇开心果编程14 小时前
【Jetpack Compose进阶学与练】第14课:系列收尾复习总结;Compose项目常见坑点汇总;学习路线与后续学习方向
android·学习·ui·kotlin·android jetpack