fix(scale): 修复主从设备IP显示异常及RecyclerView滑动崩溃

- 新增 NetworkUtil 统一获取本机IP,兼容 Android 12+(ConnectivityManager)和低版本(WifiManager)
- 主设备IP改为实时读取,修复App启动时网络未就绪导致IP缓存为空的问题
- MasterScaleActivity/SlaveActivity 关闭RecyclerView默认ItemAnimator,消除高频刷新时的闪烁
- BaseActivity.hideSystemBars 加防重入标志,避免重复调用setDecorFitsSystemWindows触发布局重绘导致滑动崩溃
- 新增 CrashHandler,崩溃时记录日志并自动重启应用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 18:09:41 +08:00
co-authored by Claude Sonnet 4.6
parent 29228b069b
commit 34adc0fe56
7 changed files with 322 additions and 29 deletions
@@ -247,16 +247,19 @@ open class BaseActivity : AppCompatActivity() {
/**
* 隐藏状态栏和导航栏
* 使用 WindowInsetsControllerCompatAndroidx 推荐方式,向下兼容性好)
* - 状态栏:完全隐藏
* - 导航栏:完全隐藏,底部上滑时临时出现,松手后自动收回(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
* - setDecorFitsSystemWindows 只在首次调用时设置,避免反复触发布局重绘
* (重绘会导致 RecyclerView 滑动时抛出 "view is not a child, cannot hide" 异常
* - 每次调用仍重新执行 hide(),保证系统栏在滑出后能被重新隐藏
*/
private var systemBarsConfigured = false
private fun hideSystemBars() {
// 允许布局延伸到系统栏区域(edge-to-edge),避免内容被系统栏遮挡
WindowCompat.setDecorFitsSystemWindows(window, false)
if (!systemBarsConfigured) {
// 只执行一次:允许布局延伸到系统栏区域
WindowCompat.setDecorFitsSystemWindows(window, false)
systemBarsConfigured = true
}
WindowInsetsControllerCompat(window, window.decorView).apply {
// 同时隐藏状态栏和导航栏
hide(WindowInsetsCompat.Type.systemBars())
// 底部上滑时临时显示导航栏,松手后自动隐藏(沉浸式 Sticky 模式)
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
@@ -14,6 +14,7 @@ import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.ui.InitActivity.Companion.TAG
import com.shuwei.dish.match.utils.AppUtil
import com.shuwei.dish.match.utils.BootReceiver
import com.shuwei.dish.match.utils.CrashHandler
class BaseApp : Application() {
@@ -23,6 +24,7 @@ class BaseApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
CrashHandler.init(this)
val deviceId = AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId")
GlobalData.deviceId = deviceId
@@ -1,10 +1,10 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.net.wifi.WifiManager
import android.util.Log
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.utils.NetworkUtil
import kotlinx.coroutines.flow.StateFlow
/**
@@ -70,17 +70,10 @@ object ScaleServiceManager {
}
/**
* 通过 WifiManager 获取本机 WiFi IP 地址
* 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容
*/
private fun getLocalIp(context: Context): String {
return try {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val ip = wm.connectionInfo.ipAddress
if (ip == 0) "" else
"${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}"
} catch (e: Exception) {
""
}
return NetworkUtil.getLocalIpAddress(context)
}
/**
@@ -14,6 +14,7 @@ import com.shuwei.dish.match.databinding.ActivityMasterScaleBinding
import com.shuwei.dish.match.databinding.ListItemScaleDataBinding
import com.shuwei.dish.match.scale.ScaleData
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.NetworkUtil
import com.shuwei.dish.match.utils.WeightUtil
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -50,6 +51,7 @@ class MasterScaleActivity : BaseActivity() {
setHeaderBackground()
binding.rvScaleList.layoutManager = LinearLayoutManager(this)
binding.rvScaleList.itemAnimator = null // 关闭默认动画,避免数据频繁更新时出现闪烁错乱
binding.rvScaleList.adapter = adapter
observeScaleData()
@@ -66,6 +68,8 @@ class MasterScaleActivity : BaseActivity() {
flow.collectLatest { scaleMap ->
// 按 deviceId 分组,本机设备排最前,其余按 deviceId 排序
val localId = com.shuwei.dish.match.base.GlobalData.deviceId
// 本机 IP 实时读取,避免 App 启动时网络未就绪导致缓存为空
val localIp = NetworkUtil.getLocalIpAddress(this@MasterScaleActivity)
val groups = scaleMap.values
.groupBy { it.deviceId }
.entries
@@ -73,7 +77,8 @@ class MasterScaleActivity : BaseActivity() {
.map { (deviceId, scales) ->
DeviceGroup(
deviceId = deviceId,
ip = scales.firstOrNull()?.ip ?: "",
ip = if (deviceId == localId) localIp
else scales.firstOrNull { it.ip.isNotEmpty() }?.ip ?: "",
scales = scales.sortedBy { it.address }
)
}
@@ -1,7 +1,6 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.net.wifi.WifiManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
@@ -12,6 +11,7 @@ import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.ActivitySlaveBinding
import com.shuwei.dish.match.databinding.ListItemScaleRowBinding
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.NetworkUtil
import com.shuwei.dish.match.utils.WeightUtil
/**
@@ -41,6 +41,7 @@ class SlaveActivity : BaseActivity() {
binding.tvDeviceId.text = "设备:${GlobalData.deviceId}"
binding.tvDeviceIp.text = "IP${getLocalIpAddress()}"
binding.rvScaleList.layoutManager = LinearLayoutManager(this)
binding.rvScaleList.itemAnimator = null // 关闭默认动画,避免数据频繁更新时出现闪烁错乱
binding.rvScaleList.adapter = adapter
updateMasterStatus(false)
@@ -101,19 +102,11 @@ class SlaveActivity : BaseActivity() {
}
/**
* 获取本机 WiFi IP 地址
* 优先使用 WifiManager获取失败时返回"未知"
* 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容
* 获取失败时返回"未知"
*/
@SuppressLint("WifiManagerLeak")
private fun getLocalIpAddress(): String {
return try {
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val ip = wifiManager.connectionInfo.ipAddress
if (ip == 0) "未知" else
"${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}"
} catch (e: Exception) {
"未知"
}
return NetworkUtil.getLocalIpAddress(this).ifEmpty { "未知" }
}
private class ScaleAdapter(private val data: List<ScaleItem>) :
@@ -0,0 +1,235 @@
package com.shuwei.dish.match.utils
import android.app.ActivityManager
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.util.Log
import com.shuwei.dish.match.ui.HomeActivity
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.system.exitProcess
/**
* 崩溃处理
*/
class CrashHandler private constructor(private val context: Context) :
Thread.UncaughtExceptionHandler {
companion object {
private const val TAG = "CrashHandler"
private const val CRASH_REPORTS_DIR = "crash_reports"
private const val LOG_LINES = 500 // 收集最近500行日志
@Volatile
private var instance: CrashHandler? = null
fun init(context: Context) {
if (instance == null) {
synchronized(CrashHandler::class.java) {
if (instance == null) {
instance = CrashHandler(context.applicationContext)
}
}
}
}
fun getCrashReportFiles(context: Context): Array<File> {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
return if (crashDir.exists() && crashDir.isDirectory) {
crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray()
} else {
emptyArray()
}
}
fun clearCrashReports(context: Context) {
getCrashReportFiles(context).forEach { it.delete() }
}
}
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
init {
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread, ex: Throwable) {
handleException(thread, ex)
}
/**
* 自动重启app
*/
private fun restartApp() {
// 延迟1秒后重启应用
Handler(Looper.getMainLooper()).postDelayed({
val intent = Intent(context, HomeActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
Process.killProcess(Process.myPid())
exitProcess(1)
}, 1000)
}
private fun handleException(thread: Thread, ex: Throwable) {
// 收集设备信息和异常信息
val crashInfo = collectCrashInfo(thread, ex)
// 保存日志文件
saveCrashInfoToFile(crashInfo)
// 这里可以添加其他处理逻辑,比如上传到服务器等
restartApp()
}
private fun collectCrashInfo(thread: Thread, ex: Throwable): String {
return buildString {
// 收集设备信息
collectDeviceInfo(this)
// 收集应用日志
try {
append("\n\n").append(collectLogs())
} catch (e: OutOfMemoryError) {
append("collectLogs发生OutOfMemoryError:${e.message}")
}
// 收集线程和异常信息
append("\n\n========== Thread & Exception Info ==========\n")
append("Thread: ${thread.name}\n")
append("Stack Trace:\n")
val sw = StringWriter()
val pw = PrintWriter(sw)
ex.printStackTrace(pw)
var cause: Throwable? = ex.cause
while (cause != null) {
cause.printStackTrace(pw)
cause = cause.cause
}
pw.close()
append(sw.toString())
}
}
private fun collectDeviceInfo(sb: StringBuilder) {
sb.append("========== Device Info ==========\n")
try {
// 应用信息
val pm = context.packageManager
val pi = pm.getPackageInfo(context.packageName, 0)
sb.append("App Version: ${pi.versionName}_${pi.versionCode}\n")
// Android 版本信息
sb.append("OS Version: ${Build.VERSION.RELEASE}_${Build.VERSION.SDK_INT}\n")
// 设备信息
sb.append("Vendor: ${Build.MANUFACTURER}\n")
sb.append("Model: ${Build.MODEL}\n")
sb.append("CPU ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}\n")
// 其他信息
sb.append("Locale: ${Locale.getDefault()}\n")
sb.append(
"Current Time: ${
SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault()
).format(Date())
}\n"
)
// 内存信息
val memoryInfo = ActivityManager.MemoryInfo()
val activityManager =
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.getMemoryInfo(memoryInfo)
sb.append("Available Memory: ${memoryInfo.availMem / (1024 * 1024)}MB\n")
sb.append("Total Memory: ${memoryInfo.totalMem / (1024 * 1024)}MB\n")
sb.append("Low Memory: ${memoryInfo.lowMemory}\n")
} catch (e: Exception) {
Log.e(TAG, "Error while collecting device info", e)
sb.append("Error while collecting device info: ${e.message}\n")
}
}
private fun collectLogs(): String {
return buildString {
append("========== Application Logs ==========\n")
try {
val process = Runtime.getRuntime().exec("logcat -d -v threadtime")
val reader = process.inputStream.bufferedReader()
val logLines = reader.readLines()
val start = kotlin.comparisons.maxOf(0, logLines.size - LOG_LINES)
logLines.subList(start, logLines.size).forEach {
append(it).append("\n")
}
} catch (e: IOException) {
Log.e(TAG, "Error collecting logs", e)
append("Error collecting logs: ${e.message}\n")
}
}
}
private fun saveCrashInfoToFile(crashInfo: String) {
try {
val time = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date())
val fileName = "crash_$time.log"
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() && !crashDir.mkdirs()) {
Log.d(TAG,"Failed to create crash report directory")
return
}
val crashFile = File(crashDir, fileName)
FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) }
Log.d(TAG,"Crash info saved to: ${crashFile.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Error saving crash info to file", e)
}
}
/**
* 清理旧的崩溃日志
*/
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
}
}
}
}
@@ -0,0 +1,62 @@
package com.shuwei.dish.match.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.wifi.WifiManager
import android.os.Build
import java.net.Inet4Address
/**
* 网络工具类
*/
object NetworkUtil {
/**
* 获取本机局域网 IPv4 地址
* - Android 12+API 31+):使用 ConnectivityManager + LinkProperties,避免废弃 API
* - Android 12 以下:回退到 WifiManager.connectionInfo.ipAddress
* @return IP 字符串,获取失败返回空字符串
*/
fun getLocalIpAddress(context: Context): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
getIpViaConnectivityManager(context)
} else {
getIpViaWifiManager(context)
}
}
/**
* Android 12+ 方式:通过 ConnectivityManager 获取活跃网络的 LinkProperties
*/
private fun getIpViaConnectivityManager(context: Context): String {
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return ""
val props: LinkProperties = cm.getLinkProperties(network) ?: return ""
props.linkAddresses
.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress ?: ""
} catch (e: Exception) {
""
}
}
/**
* Android 12 以下方式:通过 WifiManager 获取 IP(仅 WiFi 有效)
*/
@Suppress("DEPRECATION")
private fun getIpViaWifiManager(context: Context): String {
return try {
val wm = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
val ip = wm.connectionInfo.ipAddress
if (ip == 0) ""
else "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}"
} catch (e: Exception) {
""
}
}
}