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:
@@ -247,16 +247,19 @@ open class BaseActivity : AppCompatActivity() {
|
||||
/**
|
||||
* 隐藏状态栏和导航栏
|
||||
* 使用 WindowInsetsControllerCompat(Androidx 推荐方式,向下兼容性好)
|
||||
* - 状态栏:完全隐藏
|
||||
* - 导航栏:完全隐藏,底部上滑时临时出现,松手后自动收回(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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user