fix(base): 修复Loading弹窗管理和超时机制

- 添加了Loading超时自动关闭功能,默认15秒超时
- 实现了Activity销毁时只关闭自己的Loading,防止误关其他页面
- 优化了Loading重复显示时的节流处理,避免ANR问题
- 在BaseActivity中增加了showLoading方法的超时参数支持
- 为图片上传等长时间操作添加了60秒超时兜底机制
- 增加了相关日志便于调试Loading的显示和关闭流程
This commit is contained in:
2026-05-18 18:11:26 +08:00
parent e570707216
commit aa539d87d0
4 changed files with 85 additions and 18 deletions
@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
@@ -37,6 +38,7 @@ import kotlin.getValue
open class BaseActivity : AppCompatActivity() { open class BaseActivity : AppCompatActivity() {
companion object { companion object {
const val TAG = "BaseActivity"
/** 日期时间格式化器,复用避免每秒重复创建 */ /** 日期时间格式化器,复用避免每秒重复创建 */
private val DATE_FORMATTER = private val DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA) DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA)
@@ -71,7 +73,8 @@ open class BaseActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
dismissLoading() // 只关闭自己弹出的 Loading,防止误关其他 Activity 的 Loading
Loading.dismissIfOwnedBy(this)
super.onDestroy() super.onDestroy()
ActivityManager.removeActivity(this) ActivityManager.removeActivity(this)
} }
@@ -150,22 +153,25 @@ open class BaseActivity : AppCompatActivity() {
handler.removeCallbacks(updateTask) handler.removeCallbacks(updateTask)
} }
fun showLoading(msg: String = "加载中……") { fun showLoading(msg: String = "加载中……", timeoutMs: Long = Loading.DEFAULT_TIMEOUT_MS) {
handler.post { handler.post {
Loading.show( Loading.show(
context = this, context = this,
message = msg message = msg,
timeoutMs = timeoutMs
) )
} }
} }
fun dismissLoading() { fun dismissLoading() {
Log.d(TAG, "dismissLoading, ${this.javaClass.simpleName}")
handler.post { handler.post {
Loading.dismiss() Loading.dismiss()
} }
} }
fun delayDismissLoading() { fun delayDismissLoading() {
Log.d(TAG, "delayDismissLoading, ${this.javaClass.simpleName}")
handler.postDelayed({ handler.postDelayed({
Loading.dismiss() Loading.dismiss()
}, 500) }, 500)
@@ -1,17 +1,28 @@
package com.shuwei.dish.match.dialog package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.DialogLoadingBinding import com.shuwei.dish.match.databinding.DialogLoadingBinding
@SuppressLint("StaticFieldLeak")
object Loading { object Loading {
/** 默认超时兜底时长(毫秒):业务方未传值时使用 */
const val DEFAULT_TIMEOUT_MS = 15_000L
private var dialog: LoadingDialog? = null private var dialog: LoadingDialog? = null
/** 当前持有 Loading 的 context,用于隔离 Activity 销毁时的误关 */
private var owner: Context? = null
private val timeoutHandler = Handler(Looper.getMainLooper())
private val timeoutRunnable = Runnable { dismiss() }
fun isShowing(): Boolean { fun isShowing(): Boolean {
return dialog != null && dialog!!.isShowing return dialog != null && dialog!!.isShowing
@@ -22,30 +33,71 @@ object Loading {
dialog!!.updateContent(content) dialog!!.updateContent(content)
} }
fun show(context: Context, message: String = "加载中……", onDismiss: () -> Unit = {}) { /**
* 显示 Loading 弹窗
*
* @param context 上下文
* @param message 提示文案
* @param timeoutMs 超时兜底时长(毫秒):到期自动 dismiss;传 0 关闭兜底,由业务方自己负责
* @param onDismiss 关闭回调
*/
fun show(
context: Context,
message: String = "加载中……",
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
onDismiss: () -> Unit = {}
) {
try { try {
// 节流:已显示中只更新文案 + 重置超时,不重新 attach 窗口
// 避免反复触发 FocusEvent 在主线程繁忙时引发 ANR
if (dialog?.isShowing == true) { if (dialog?.isShowing == true) {
dialog?.dismiss() dialog?.updateContent(message)
} owner = context // 同一弹窗最后一次 show 决定 owner
if (dialog == null) { resetTimeout(timeoutMs)
dialog = LoadingDialog(context = context, message = message, onDismiss = onDismiss) return
} }
dialog = LoadingDialog(context = context, message = message, onDismiss = onDismiss)
owner = context
dialog?.show() dialog?.show()
resetTimeout(timeoutMs)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
} }
fun dismiss() { fun dismiss() {
timeoutHandler.removeCallbacks(timeoutRunnable)
try { try {
dialog?.let { dialog?.let {
if (it.isShowing) { if (it.isShowing) {
it.dismiss() it.dismiss()
} }
} }
dialog = null
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally {
dialog = null
owner = null
}
}
/**
* Activity 销毁专用:只关闭自己持有的 Loading,防止跨 Activity 误关
* 单例 Loading 在多 Activity 共享时,若每个 Activity 的 onDestroy 都无脑 dismiss
* 会误关后启动 Activity 弹出的 Loading;由本方法做 owner 校验后才允许关闭
*/
fun dismissIfOwnedBy(context: Context) {
if (owner === context) dismiss()
}
/**
* 重置超时兜底定时器
* @param timeoutMs <=0 表示不安排定时器(关闭兜底)
*/
private fun resetTimeout(timeoutMs: Long) {
timeoutHandler.removeCallbacks(timeoutRunnable)
if (timeoutMs > 0) {
timeoutHandler.postDelayed(timeoutRunnable, timeoutMs)
} }
} }
@@ -145,6 +145,8 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Log.d(TAG, "onResume, dinnerType=$dinnerType, isVisible=${isVisible}, isHidden=${isHidden}")
if (isHidden) return // 过滤 Activity 回前台时隐藏 tab 的无效触发
runCatching { runCatching {
pageNo = 1 pageNo = 1
getDishList(currentFoodName) getDishList(currentFoodName)
@@ -226,16 +228,19 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
val localList = if (pageNo == 1) pendingLocalList else null val localList = if (pageNo == 1) pendingLocalList else null
netViewModel.searchFoodListWithCallback( netViewModel.searchFoodListWithCallback(
param = param, param = param,
onLoading = { activity.showLoading() } onLoading = { activity.showLoading(timeoutMs = 35_000L) }
) { result -> ) { result ->
when (result) { when (result) {
is UiState.Success -> { is UiState.Success -> {
if (isAdded.not()) return@searchFoodListWithCallback
Log.d(TAG, "getDishList, UiState.Success")
activity.delayDismissLoading() activity.delayDismissLoading()
finishRefresh() finishRefresh()
loadAndMergeDishList(result.data, localList) loadAndMergeDishList(result.data, localList)
} }
is UiState.Error -> { is UiState.Error -> {
if (isAdded.not()) return@searchFoodListWithCallback if (isAdded.not()) return@searchFoodListWithCallback
Log.d(TAG, "getDishList, UiState.Error")
toast(result.msg) toast(result.msg)
finishRefresh() finishRefresh()
activity.delayDismissLoading() activity.delayDismissLoading()
@@ -334,7 +334,9 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
private fun upload() { private fun upload() {
lifecycleScope.launch { lifecycleScope.launch {
val totalFileCount = vectorList.count { it.imageFile != null } val totalFileCount = vectorList.count { it.imageFile != null }
showWaitingDialog("图片上传中0/$totalFileCount") // 上传场景单批可能耗时较长(含图片传输 + 网络往返),用 60s 超时兜底
// 每次 onProgress 都会重新调用 showWaitingDialog,会重置定时器,正常进度不会触发兜底
showWaitingDialog("图片上传中0/$totalFileCount", timeoutMs = 60_000L)
val params = mutableMapOf<String, RequestBody>() val params = mutableMapOf<String, RequestBody>()
params["goodsId"] = checkedItem!!.goodsId.toRequestBody() params["goodsId"] = checkedItem!!.goodsId.toRequestBody()
params["goodsName"] = checkedItem!!.goodsName!!.toRequestBody() params["goodsName"] = checkedItem!!.goodsName!!.toRequestBody()
@@ -358,7 +360,7 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
log("uploadImage返回:result=${result.toJsonString()}") log("uploadImage返回:result=${result.toJsonString()}")
if (result is UiState.Success) result.data else null if (result is UiState.Success) result.data else null
}, onProgress = { count, batch, idList -> }, onProgress = { count, batch, idList ->
showWaitingDialog("图片上传中$count/$totalFileCount") showWaitingDialog("图片上传中$count/$totalFileCount", timeoutMs = 60_000L)
val foodList = batch.mapIndexed { index, it -> val foodList = batch.mapIndexed { index, it ->
val name = checkedItem!!.goodsName + "&&" + checkedItem!!.rawMaterialsType val name = checkedItem!!.goodsName + "&&" + checkedItem!!.rawMaterialsType
Food( Food(
@@ -507,12 +509,14 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
currentActivity.dismissLoading() currentActivity.dismissLoading()
} }
private fun showWaitingDialog(msg: String) { /**
if (Loading.isShowing()) { * 显示等待弹窗
Loading.updateContent(msg) * @param msg 提示文案
return * @param timeoutMs 超时兜底时长(毫秒):到期自动关闭,传 0 关闭兜底
} * Loading 内部已做节流,重复调用只会更新文案 + 重置超时定时器
currentActivity.showLoading(msg) */
private fun showWaitingDialog(msg: String, timeoutMs: Long = Loading.DEFAULT_TIMEOUT_MS) {
currentActivity.showLoading(msg, timeoutMs)
} }
private lateinit var previewView: PreviewView private lateinit var previewView: PreviewView