From 935dba200ea87742ab741cb4d3e497c6e5c49185 Mon Sep 17 00:00:00 2001 From: ZhanGSKen Date: Tue, 11 Nov 2025 20:31:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BA=90=E7=A0=81=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appbase/build.properties | 4 +- libappbase/build.properties | 4 +- .../studio/libappbase/CrashHandler.java | 769 +++++++++++------- .../libappbase/GlobalCrashReportView.java | 369 ++++++--- .../studio/libappbase/HorizontalListView.java | 309 ++++--- .../studio/libappbase/LogActivity.java | 37 +- 6 files changed, 969 insertions(+), 523 deletions(-) diff --git a/appbase/build.properties b/appbase/build.properties index 599611e2..3f261f9e 100644 --- a/appbase/build.properties +++ b/appbase/build.properties @@ -1,8 +1,8 @@ #Created by .winboll/winboll_app_build.gradle -#Tue Nov 11 12:05:37 GMT 2025 +#Tue Nov 11 12:22:58 GMT 2025 stageCount=10 libraryProject=libappbase baseVersion=15.10 publishVersion=15.10.9 -buildCount=3 +buildCount=4 baseBetaVersion=15.10.10 diff --git a/libappbase/build.properties b/libappbase/build.properties index 599611e2..3f261f9e 100644 --- a/libappbase/build.properties +++ b/libappbase/build.properties @@ -1,8 +1,8 @@ #Created by .winboll/winboll_app_build.gradle -#Tue Nov 11 12:05:37 GMT 2025 +#Tue Nov 11 12:22:58 GMT 2025 stageCount=10 libraryProject=libappbase baseVersion=15.10 publishVersion=15.10.9 -buildCount=3 +buildCount=4 baseBetaVersion=15.10.10 diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/CrashHandler.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/CrashHandler.java index 5b97af6c..45946628 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/CrashHandler.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/CrashHandler.java @@ -1,9 +1,11 @@ package cc.winboll.studio.libappbase; /** - * @Author ZhanGSKen - * @Date 2024/08/12 13:22:12 - * @Describe 异常处理类 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/11 20:14 + * @Describe * 应用全局崩溃处理类(单例逻辑) + * 核心功能:捕获应用未捕获异常,记录崩溃日志到文件,启动崩溃报告页面, + * 并通过「崩溃保险丝」机制防止重复崩溃,保障基础功能可用 */ import android.app.Activity; import android.app.Application; @@ -12,29 +14,22 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; -import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.res.Resources; import android.graphics.Color; -import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.text.SpannableString; import android.text.TextUtils; -import android.text.style.ForegroundColorSpan; import android.view.Menu; import android.view.MenuItem; import android.view.ViewGroup; import android.widget.HorizontalScrollView; -import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; -import android.widget.Toolbar; -import cc.winboll.studio.libappbase.R; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -50,356 +45,506 @@ import java.util.Locale; public final class CrashHandler { - public static final String TAG = "CrashHandler"; + /** 日志标签,用于当前类的日志输出标识 */ + public static final String TAG = "CrashHandler"; - public static final String TITTLE = "CrashReport"; + /** 崩溃报告页面标题 */ + public static final String TITTLE = "CrashReport"; - public static final String EXTRA_CRASH_INFO = "crashInfo"; + /** Intent 传递崩溃信息的键(用于向崩溃页面传递日志) */ + public static final String EXTRA_CRASH_INFO = "crashInfo"; - final static String PREFS = CrashHandler.class.getName() + "PREFS"; - final static String PREFS_CRASHHANDLER_ISCRASHHAPPEN = "PREFS_CRASHHANDLER_ISCRASHHAPPEN"; + /** SharedPreferences 存储键(用于记录崩溃状态) */ + final static String PREFS = CrashHandler.class.getName() + "PREFS"; + /** SharedPreferences 中存储「是否发生崩溃」的键 */ + final static String PREFS_CRASHHANDLER_ISCRASHHAPPEN = "PREFS_CRASHHANDLER_ISCRASHHAPPEN"; - public static String _CrashCountFilePath; + /** 崩溃保险丝状态文件路径(存储当前熔断等级) */ + public static String _CrashCountFilePath; - public static final UncaughtExceptionHandler DEFAULT_UNCAUGHT_EXCEPTION_HANDLER = Thread.getDefaultUncaughtExceptionHandler(); + /** 系统默认的未捕获异常处理器(用于降级处理,避免 CrashHandler 自身崩溃) */ + public static final UncaughtExceptionHandler DEFAULT_UNCAUGHT_EXCEPTION_HANDLER = Thread.getDefaultUncaughtExceptionHandler(); - public static void init(Application app) { - _CrashCountFilePath = app.getExternalFilesDir("CrashHandler") + "/IsCrashHandlerCrashHappen.dat"; - LogUtils.d(TAG, String.format("_CrashCountFilePath %s", _CrashCountFilePath)); - init(app, null); - } + /** + * 初始化崩溃处理器(默认存储路径) + * 调用重载方法,崩溃日志默认存储在应用外部私有目录的 crash 文件夹下 + * @param app 全局 Application 实例(用于获取存储目录、包信息等) + */ + public static void init(Application app) { + // 初始化崩溃保险丝状态文件路径(外部存储/CrashHandler/IsCrashHandlerCrashHappen.dat) + _CrashCountFilePath = app.getExternalFilesDir("CrashHandler") + "/IsCrashHandlerCrashHappen.dat"; + LogUtils.d(TAG, String.format("_CrashCountFilePath %s", _CrashCountFilePath)); + // 调用带目录参数的初始化方法,传入 null 使用默认路径 + init(app, null); + } - public static void init(final Application app, final String crashDir) { - Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler(){ + /** + * 初始化崩溃处理器(指定日志存储目录) + * 替换系统默认的未捕获异常处理器,自定义崩溃处理逻辑 + * @param app 全局 Application 实例 + * @param crashDir 崩溃日志存储目录(null 则使用默认路径) + */ + public static void init(final Application app, final String crashDir) { + // 设置自定义未捕获异常处理器 + Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + try { + // 尝试处理崩溃(捕获内部异常,避免 CrashHandler 自身崩溃) + tryUncaughtException(thread, throwable); + } catch (Throwable e) { + e.printStackTrace(); + // 处理失败时,交给系统默认处理器兜底 + if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) { + DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable); + } + } + } - @Override - public void uncaughtException(Thread thread, Throwable throwable) { - try { - tryUncaughtException(thread, throwable); - } catch (Throwable e) { - e.printStackTrace(); - if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) - DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable); - } - } + /** + * 实际处理崩溃的核心方法 + * 1. 熔断保险丝(记录崩溃次数);2. 收集崩溃信息;3. 写入日志文件;4. 启动崩溃报告页面 + * @param thread 发生崩溃的线程 + * @param throwable 崩溃异常对象(包含堆栈信息) + */ + private void tryUncaughtException(Thread thread, Throwable throwable) { + // 触发崩溃保险丝(每次崩溃熔断一次,降低防护等级) + AppCrashSafetyWire.getInstance().burnSafetyWire(); - private void tryUncaughtException(Thread thread, Throwable throwable) { - // 每到这里就燃烧一次保险丝 - AppCrashSafetyWire.getInstance().burnSafetyWire(); + // 格式化崩溃发生时间(用于日志文件名和内容) + final String time = new SimpleDateFormat("yyyy_MM_dd-HH_mm_ss", Locale.getDefault()).format(new Date()); + // 创建崩溃日志文件(默认路径:外部存储/crash/[时间].txt) + File crashFile = new File( + TextUtils.isEmpty(crashDir) ? new File(app.getExternalFilesDir(null), "crash") : new File(crashDir), + "crash_" + time + ".txt" + ); - final String time = new SimpleDateFormat("yyyy_MM_dd-HH_mm_ss", Locale.getDefault()).format(new Date()); - File crashFile = new File(TextUtils.isEmpty(crashDir) ? new File(app.getExternalFilesDir(null), "crash") - : new File(crashDir), "crash_" + time + ".txt"); + // 获取应用版本信息(版本名、版本号) + String versionName = "unknown"; + long versionCode = 0; + try { + PackageInfo packageInfo = app.getPackageManager().getPackageInfo(app.getPackageName(), 0); + versionName = packageInfo.versionName; + // 适配 Android 9.0+(API 28)的版本号获取方式 + versionCode = Build.VERSION.SDK_INT >= 28 ? packageInfo.getLongVersionCode() : packageInfo.versionCode; + } catch (PackageManager.NameNotFoundException ignored) {} - String versionName = "unknown"; - long versionCode = 0; - try { - PackageInfo packageInfo = app.getPackageManager().getPackageInfo(app.getPackageName(), 0); - versionName = packageInfo.versionName; - versionCode = Build.VERSION.SDK_INT >= 28 ? packageInfo.getLongVersionCode() - : packageInfo.versionCode; - } catch (PackageManager.NameNotFoundException ignored) {} + // 将异常堆栈信息转换为字符串 + String fullStackTrace; + { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + throwable.printStackTrace(pw); // 将异常堆栈写入 PrintWriter + fullStackTrace = sw.toString(); + pw.close(); + } - String fullStackTrace; { - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - throwable.printStackTrace(pw); - fullStackTrace = sw.toString(); - pw.close(); - } + // 拼接崩溃信息(设备信息 + 应用信息 + 堆栈信息) + StringBuilder sb = new StringBuilder(); + sb.append("************* Crash Head ****************\n"); + sb.append("Time Of Crash : ").append(time).append("\n"); + sb.append("Device Manufacturer : ").append(Build.MANUFACTURER).append("\n"); // 设备厂商 + sb.append("Device Model : ").append(Build.MODEL).append("\n"); // 设备型号 + sb.append("Android Version : ").append(Build.VERSION.RELEASE).append("\n"); // Android 版本 + sb.append("Android SDK : ").append(Build.VERSION.SDK_INT).append("\n"); // SDK 版本 + sb.append("App VersionName : ").append(versionName).append("\n"); // 应用版本名 + sb.append("App VersionCode : ").append(versionCode).append("\n"); // 应用版本号 + sb.append("************* Crash Head ****************\n"); + sb.append("\n").append(fullStackTrace); // 拼接异常堆栈 - StringBuilder sb = new StringBuilder(); - sb.append("************* Crash Head ****************\n"); - sb.append("Time Of Crash : ").append(time).append("\n"); - sb.append("Device Manufacturer : ").append(Build.MANUFACTURER).append("\n"); - sb.append("Device Model : ").append(Build.MODEL).append("\n"); - sb.append("Android Version : ").append(Build.VERSION.RELEASE).append("\n"); - sb.append("Android SDK : ").append(Build.VERSION.SDK_INT).append("\n"); - sb.append("App VersionName : ").append(versionName).append("\n"); - sb.append("App VersionCode : ").append(versionCode).append("\n"); - sb.append("************* Crash Head ****************\n"); - sb.append("\n").append(fullStackTrace); + final String errorLog = sb.toString(); - String errorLog = sb.toString(); + // 将崩溃日志写入文件(忽略写入失败) + try { + writeFile(crashFile, errorLog); + } catch (IOException ignored) {} - try { - writeFile(crashFile, errorLog); - } catch (IOException ignored) {} + // 启动崩溃报告页面(标签用于代码块折叠) + gotoCrashActiviy: { + Intent intent = new Intent(); + LogUtils.d(TAG, "gotoCrashActiviy: "); - gotoCrashActiviy: { - Intent intent = new Intent(); - LogUtils.d(TAG, "gotoCrashActiviy: "); - if (AppCrashSafetyWire.getInstance().isAppCrashSafetyWireOK()) { - LogUtils.d(TAG, "gotoCrashActiviy: isAppCrashSafetyWireOK"); - intent.setClass(app, GlobalCrashActivity.class); - intent.putExtra(EXTRA_CRASH_INFO, errorLog); - // 如果发生了 CrashHandler 内部崩溃, 就调用基础的应用崩溃显示类 -// intent.setClass(app, GlobalCrashActiviy.class); -// intent.putExtra(GlobalCrashActiviy.EXTRA_CRASH_INFO, errorLog); - } else { - LogUtils.d(TAG, "gotoCrashActiviy: else"); - // 正常状态调用进阶的应用崩溃显示页 - intent.setClass(app, CrashActivity.class); - intent.putExtra(EXTRA_CRASH_INFO, errorLog); - } + // 根据保险丝状态选择启动的崩溃页面 + if (AppCrashSafetyWire.getInstance().isAppCrashSafetyWireOK()) { + LogUtils.d(TAG, "gotoCrashActiviy: isAppCrashSafetyWireOK"); + // 保险丝正常:启动自定义样式的崩溃报告页面(GlobalCrashActivity) + intent.setClass(app, GlobalCrashActivity.class); + intent.putExtra(EXTRA_CRASH_INFO, errorLog); // 传递崩溃日志 + } else { + LogUtils.d(TAG, "gotoCrashActiviy: else"); + // 保险丝熔断:启动基础版崩溃页面(CrashActivity,避免复杂页面再次崩溃) + intent.setClass(app, CrashActivity.class); + intent.putExtra(EXTRA_CRASH_INFO, errorLog); + } + // 设置意图标志:清除原有任务栈,创建新任务(避免回到崩溃前页面) + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_CLEAR_TASK + ); + try { + // 启动崩溃页面,终止当前进程(确保完全重启) + app.startActivity(intent); + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(0); + } catch (ActivityNotFoundException e) { + // 未找到崩溃页面(如未在 Manifest 注册),交给系统默认处理器 + e.printStackTrace(); + if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) { + DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable); + } + } catch (Exception e) { + // 其他异常,兜底处理 + e.printStackTrace(); + if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) { + DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable); + } + } + } + } -// intent.setClass(app, CrashActiviy.class); -// intent.putExtra(CrashActiviy.EXTRA_CRASH_INFO, errorLog); + /** + * 将字符串内容写入文件(创建父目录、覆盖写入) + * @param file 目标文件(包含路径) + * @param content 待写入的内容(崩溃日志) + * @throws IOException 文件创建或写入失败时抛出 + */ + private void writeFile(File file, String content) throws IOException { + File parentFile = file.getParentFile(); + // 父目录不存在则创建 + if (parentFile != null && !parentFile.exists()) { + parentFile.mkdirs(); + } + file.createNewFile(); // 创建文件 + FileOutputStream fos = new FileOutputStream(file); + fos.write(content.getBytes()); // 写入内容(默认 UTF-8 编码) + try { + fos.close(); // 关闭流 + } catch (IOException e) {} + } + }); + } + /** + * 应用崩溃保险丝内部类(单例) + * 核心作用:限制短时间内重复崩溃,通过「熔断等级」控制崩溃页面启动策略 + * 等级范围:MINI(1)~ MAX(2),每次崩溃等级-1,熔断后启动基础版崩溃页面 + */ + public static final class AppCrashSafetyWire { - intent.addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_CLEAR_TASK - ); + /** 单例实例(volatile 保证多线程可见性) */ + private static volatile AppCrashSafetyWire _AppCrashSafetyWire; - try { - app.startActivity(intent); - android.os.Process.killProcess(android.os.Process.myPid()); - System.exit(0); - } catch (ActivityNotFoundException e) { - e.printStackTrace(); - if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) - DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable); - } catch (Exception e) { - e.printStackTrace(); - if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) - DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable); - } - } + /** 当前熔断等级(1:最低防护;2:最高防护;≤0:熔断) */ + private volatile Integer currentSafetyLevel; + /** 最低熔断等级(1,再崩溃则熔断) */ + private static final int _MINI = 1; + /** 最高熔断等级(2,初始状态) */ + private static final int _MAX = 2; - } + /** + * 私有构造方法(单例模式,禁止外部实例化) + * 初始化时加载本地存储的熔断等级 + */ + private AppCrashSafetyWire() { + LogUtils.d(TAG, "AppCrashSafetyWire()"); + currentSafetyLevel = loadCurrentSafetyLevel(); + } - private void writeFile(File file, String content) throws IOException { - File parentFile = file.getParentFile(); - if (parentFile != null && !parentFile.exists()) { - parentFile.mkdirs(); - } - file.createNewFile(); - FileOutputStream fos = new FileOutputStream(file); - fos.write(content.getBytes()); - try { - fos.close(); - } catch (IOException e) {} - } + /** + * 获取单例实例(双重检查锁定,线程安全) + * @return AppCrashSafetyWire 单例 + */ + public static synchronized AppCrashSafetyWire getInstance() { + if (_AppCrashSafetyWire == null) { + _AppCrashSafetyWire = new AppCrashSafetyWire(); + } + return _AppCrashSafetyWire; + } - }); - } + /** + * 设置当前熔断等级(内存中) + * @param currentSafetyLevel 目标等级(1~2) + */ + public void setCurrentSafetyLevel(int currentSafetyLevel) { + this.currentSafetyLevel = currentSafetyLevel; + } - // - // 应用崩溃保险丝 - // - public static final class AppCrashSafetyWire { + /** + * 获取当前熔断等级(内存中) + * @return 当前等级(1~2 或 null) + */ + public int getCurrentSafetyLevel() { + return currentSafetyLevel; + } - volatile static AppCrashSafetyWire _AppCrashSafetyWire; + /** + * 保存熔断等级到本地文件(持久化,重启应用生效) + * @param currentSafetyLevel 待保存的等级 + */ + public void saveCurrentSafetyLevel(int currentSafetyLevel) { + LogUtils.d(TAG, "saveCurrentSafetyLevel()"); + this.currentSafetyLevel = currentSafetyLevel; + try { + // 序列化等级到文件(ObjectOutputStream 写入 int) + ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(_CrashCountFilePath)); + oos.writeInt(currentSafetyLevel); + oos.flush(); + oos.close(); + LogUtils.d(TAG, String.format("saveCurrentSafetyLevel writeInt currentSafetyLevel %d", currentSafetyLevel)); + } catch (IOException e) { + LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); + } + } - volatile Integer currentSafetyLevel; // 熔断值,为 0 表示熔断了。 - private static final int _MINI = 1; - private static final int _MAX = 2; + /** + * 从本地文件加载熔断等级(应用启动时初始化) + * @return 加载的等级(文件不存在则初始化为 MAX(2)) + */ + public int loadCurrentSafetyLevel() { + LogUtils.d(TAG, "loadCurrentSafetyLevel()"); + try { + File f = new File(_CrashCountFilePath); + if (f.exists()) { + // 反序列化从文件读取等级 + ObjectInputStream ois = new ObjectInputStream(new FileInputStream(_CrashCountFilePath)); + currentSafetyLevel = ois.readInt(); + LogUtils.d(TAG, String.format("loadCurrentSafetyLevel() readInt currentSafetyLevel %d", currentSafetyLevel)); + } else { + // 文件不存在,初始化等级为最高(2)并保存 + currentSafetyLevel = _MAX; + LogUtils.d(TAG, String.format("loadCurrentSafetyLevel() currentSafetyLevel init to _MAX->%d", _MAX)); + saveCurrentSafetyLevel(currentSafetyLevel); + } + } catch (IOException e) { + LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); + } + return currentSafetyLevel; + } - AppCrashSafetyWire() { - LogUtils.d(TAG, "AppCrashSafetyWire()"); - currentSafetyLevel = loadCurrentSafetyLevel(); - } + /** + * 熔断保险丝(每次崩溃调用,降低防护等级) + * @return 熔断后是否仍在防护范围内(true:是;false:已熔断) + */ + boolean burnSafetyWire() { + LogUtils.d(TAG, "burnSafetyWire()"); + // 加载当前等级 + int safeLevel = loadCurrentSafetyLevel(); + // 若在防护范围内(1~2),等级-1 并保存 + if (isSafetyWireWorking(safeLevel)) { + LogUtils.d(TAG, "burnSafetyWire() use"); + saveCurrentSafetyLevel(safeLevel - 1); + // 返回熔断后的状态 + return isSafetyWireWorking(safeLevel - 1); + } + return false; + } - public static synchronized AppCrashSafetyWire getInstance() { - if (_AppCrashSafetyWire == null) { - _AppCrashSafetyWire = new AppCrashSafetyWire(); - } - return _AppCrashSafetyWire; - } + /** + * 检查熔断等级是否在有效范围内(1~2) + * @param safetyLevel 待检查的等级 + * @return true:在范围内(防护有效);false:超出范围(已熔断) + */ + boolean isSafetyWireWorking(int safetyLevel) { + LogUtils.d(TAG, "isSafetyWireOK()"); + LogUtils.d(TAG, String.format("SafetyLevel %d", safetyLevel)); - public void setCurrentSafetyLevel(int currentSafetyLevel) { - this.currentSafetyLevel = currentSafetyLevel; - } + if (safetyLevel >= _MINI && safetyLevel <= _MAX) { + LogUtils.d(TAG, String.format("In Safety Level")); + return true; + } + LogUtils.d(TAG, String.format("Out of Safety Level")); + return false; + } - public int getCurrentSafetyLevel() { - return currentSafetyLevel; - } + /** + * 立即恢复熔断等级到最高(2) + * 用于重启应用后重置防护状态 + */ + void resumeToMaximumImmediately() { + LogUtils.d(TAG, "resumeToMaximumImmediately() call saveCurrentSafetyLevel(_MAX)"); + AppCrashSafetyWire.getInstance().saveCurrentSafetyLevel(_MAX); + } - public void saveCurrentSafetyLevel(int currentSafetyLevel) { - LogUtils.d(TAG, "saveCurrentSafetyLevel()"); - this.currentSafetyLevel = currentSafetyLevel; - try { - ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(_CrashCountFilePath)); - oos.writeInt(currentSafetyLevel); - oos.flush(); - oos.close(); - LogUtils.d(TAG, String.format("saveCurrentSafetyLevel writeInt currentSafetyLevel %d", currentSafetyLevel)); - } catch (IOException e) { - LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); - } - } + /** + * 关闭防护(设置等级为最低(1)) + * 下次崩溃直接熔断 + */ + void off() { + LogUtils.d(TAG, "off()"); + saveCurrentSafetyLevel(_MINI); + } - public int loadCurrentSafetyLevel() { - LogUtils.d(TAG, "loadCurrentSafetyLevel()"); - try { - File f = new File(_CrashCountFilePath); - if (f.exists()) { - ObjectInputStream ois = new ObjectInputStream(new FileInputStream(_CrashCountFilePath)); - currentSafetyLevel = ois.readInt(); - LogUtils.d(TAG, String.format("loadCurrentSafetyLevel() readInt currentSafetyLevel %d", currentSafetyLevel)); - } else { - currentSafetyLevel = _MAX; - LogUtils.d(TAG, String.format("loadCurrentSafetyLevel() currentSafetyLevel init to _MAX->%d", _MAX)); - saveCurrentSafetyLevel(currentSafetyLevel); - } - } catch (IOException e) { - LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); - } - return currentSafetyLevel; - } + /** + * 检查当前保险丝是否有效(防护未熔断) + * @return true:有效(等级 1~2);false:已熔断 + */ + boolean isAppCrashSafetyWireOK() { + LogUtils.d(TAG, "isAppCrashSafetyWireOK()"); + currentSafetyLevel = loadCurrentSafetyLevel(); + return isSafetyWireWorking(currentSafetyLevel); + } - boolean burnSafetyWire() { - LogUtils.d(TAG, "burnSafetyWire()"); - // 崩溃计数进入崩溃保险值 - int safeLevel = loadCurrentSafetyLevel(); - if (isSafetyWireWorking(safeLevel)) { - // 如果保险丝未熔断, 就增加一次熔断值 - LogUtils.d(TAG, "burnSafetyWire() use"); - saveCurrentSafetyLevel(safeLevel - 1); - return isSafetyWireWorking(safeLevel - 1); - } - return false; - } + /** + * 延迟恢复保险丝到最高等级(500ms 后) + * 核心作用:崩溃页面启动后,若下次即将熔断,提前恢复防护等级,避免持续崩溃 + * @param context 上下文(用于获取主线程 Handler) + */ + void postResumeCrashSafetyWireHandler(final Context context) { + // 主线程延迟 500ms 执行(避免页面启动时阻塞) + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + LogUtils.d(TAG, "Handler run()"); + // 检查:若当前等级-1 后超出防护范围(即将熔断),则恢复到最高等级 + if (!AppCrashSafetyWire.getInstance().isSafetyWireWorking(currentSafetyLevel - 1)) { + AppCrashSafetyWire.getInstance().resumeToMaximumImmediately(); + LogUtils.d(TAG, "postResumeCrashSafetyWireHandler: 恢复保险丝到最高等级"); + } + } + }, 500); + } + } - boolean isSafetyWireWorking(int safetyLevel) { - LogUtils.d(TAG, "isSafetyWireOK()"); - //safetyLevel = _MINI; - //safetyLevel = _MINI - 1; - //safetyLevel = _MINI + 1; - //safetyLevel = _MAX; - //safetyLevel = _MAX + 1; - LogUtils.d(TAG, String.format("SafetyLevel %d", safetyLevel)); + /** + * 基础版崩溃报告页面(保险丝熔断时启动) + * 极简实现:仅展示崩溃日志,提供复制、重启功能,避免复杂布局导致二次崩溃 + */ + public static final class CrashActivity extends Activity implements MenuItem.OnMenuItemClickListener { + /** 菜单标识:复制崩溃日志 */ + private static final int MENUITEM_COPY = 0; + /** 菜单标识:重启应用 */ + private static final int MENUITEM_RESTART = 1; - if (safetyLevel >= _MINI && safetyLevel <= _MAX) { - // 如果在保险值之内 - LogUtils.d(TAG, String.format("In Safety Level")); - return true; - } - LogUtils.d(TAG, String.format("Out of Safety Level")); - return false; - } + /** 崩溃日志文本(从 CrashHandler 传递过来) */ + private String mLog; - void resumeToMaximumImmediately() { - LogUtils.d(TAG, "resumeToMaximumImmediately() call saveCurrentSafetyLevel(_MAX)"); - AppCrashSafetyWire.getInstance().saveCurrentSafetyLevel(_MAX); - } + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // 初始化崩溃保险丝延迟恢复机制 + AppCrashSafetyWire.getInstance().postResumeCrashSafetyWireHandler(getApplicationContext()); - void off() { - LogUtils.d(TAG, "off()"); - saveCurrentSafetyLevel(_MINI); - } + // 获取传递的崩溃日志 + mLog = getIntent().getStringExtra(EXTRA_CRASH_INFO); + // 设置系统默认主题(避免自定义主题冲突) + setTheme(android.R.style.Theme_DeviceDefault_Light_DarkActionBar); - boolean isAppCrashSafetyWireOK() { - LogUtils.d(TAG, "isAppCrashSafetyWireOK()"); - currentSafetyLevel = loadCurrentSafetyLevel(); - return isSafetyWireWorking(currentSafetyLevel); - } + // 动态创建布局(避免 XML 布局加载异常) + setContentView: { + // 垂直滚动视图(处理日志过长) + ScrollView contentView = new ScrollView(this); + contentView.setFillViewport(true); - // 调用函数以启用持续崩溃保险,从而调用 CrashHandler 内部崩溃处理窗口 - void postResumeCrashSafetyWireHandler(final Context context) { - new Handler(Looper.getMainLooper()).postDelayed(new Runnable(){ - @Override - public void run() { - LogUtils.d(TAG, "Handler run()"); - if (!AppCrashSafetyWire.getInstance().isSafetyWireWorking(currentSafetyLevel - 1)) { - // 如果下一次应用崩溃时,保险丝熔断,则先恢复保险丝满能状态 - // 进程持续运行时,恢复保险丝熔断值 - //Resume to maximum - AppCrashSafetyWire.getInstance().resumeToMaximumImmediately(); - LogUtils.d(TAG, "postResumeCrashSafetyWireHandler"); - } - } - }, 500); - } - } + // 水平滚动视图(处理日志行过长) + HorizontalScrollView hw = new HorizontalScrollView(this); + hw.setBackgroundColor(Color.GRAY); // 背景色设为灰色 - public static final class CrashActivity extends Activity implements MenuItem.OnMenuItemClickListener { - private static final int MENUITEM_COPY = 0; - private static final int MENUITEM_RESTART = 1; + // 日志显示文本框 + TextView message = new TextView(this); + { + int padding = dp2px(16); // 内边距 16dp(适配不同屏幕) + message.setPadding(padding, padding, padding, padding); + message.setText(mLog); // 设置崩溃日志 + message.setTextColor(Color.BLACK); // 文字黑色 + message.setTextIsSelectable(true); // 支持文本选择(便于手动复制) + } - private String mLog; + // 组装布局:TextView -> HorizontalScrollView -> ScrollView + hw.addView(message); + contentView.addView(hw, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + // 设置当前 Activity 布局 + setContentView(contentView); - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - AppCrashSafetyWire.getInstance().postResumeCrashSafetyWireHandler(getApplicationContext()); + // 配置 ActionBar 标题和副标题 + getActionBar().setTitle(TITTLE); + getActionBar().setSubtitle(GlobalApplication.class.getSimpleName() + " Error"); + } + } - mLog = getIntent().getStringExtra(EXTRA_CRASH_INFO); - setTheme(android.R.style.Theme_DeviceDefault_Light_DarkActionBar); - setContentView: { - ScrollView contentView = new ScrollView(this); - contentView.setFillViewport(true); + /** + * 重写返回键逻辑:点击返回键直接重启应用 + */ + @Override + public void onBackPressed() { + restart(); + } - HorizontalScrollView hw = new HorizontalScrollView(this); - hw.setBackgroundColor(Color.GRAY); - TextView message = new TextView(this); { - int padding = dp2px(16); - message.setPadding(padding, padding, padding, padding); - message.setText(mLog); - message.setTextColor(Color.BLACK); - message.setTextIsSelectable(true); - } - hw.addView(message); + /** + * 重启当前应用(与 GlobalCrashActivity 逻辑一致) + * 清除任务栈,启动主 Activity,终止当前进程 + */ + private void restart() { + PackageManager pm = getPackageManager(); + // 获取应用启动意图(默认启动主 Activity) + Intent intent = pm.getLaunchIntentForPackage(getPackageName()); + if (intent != null) { + // 设置意图标志:清除原有任务栈,创建新任务 + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_CLEAR_TASK + ); + startActivity(intent); + } + // 关闭当前页面,终止进程,确保完全重启 + finish(); + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(0); + } - contentView.addView(hw, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); - setContentView(contentView); - getActionBar().setTitle(TITTLE); - getActionBar().setSubtitle(GlobalApplication.class.getSimpleName() + " Error"); - } - } + /** + * dp 转 px(适配不同屏幕密度) + * @param dpValue dp 值 + * @return 转换后的 px 值 + */ + private int dp2px(final float dpValue) { + final float scale = Resources.getSystem().getDisplayMetrics().density; + return (int) (dpValue * scale + 0.5f); // 四舍五入确保精度 + } - @Override - public void onBackPressed() { - restart(); - } + /** + * 菜单点击事件回调(处理复制、重启) + * @param item 被点击的菜单项 + * @return false:不消费事件(保持默认行为) + */ + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case MENUITEM_COPY: + // 复制日志到剪贴板 + ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + cm.setPrimaryClip(ClipData.newPlainText(getPackageName(), mLog)); + Toast.makeText(getApplication(), "The text is copied.", Toast.LENGTH_SHORT).show(); + break; + case MENUITEM_RESTART: + // 恢复保险丝到最高等级,然后重启应用 + AppCrashSafetyWire.getInstance().resumeToMaximumImmediately(); + restart(); + break; + } + return false; + } - private void restart() { - PackageManager pm = getPackageManager(); - Intent intent = pm.getLaunchIntentForPackage(getPackageName()); - if (intent != null) { - intent.addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_CLEAR_TASK - ); - startActivity(intent); - } - finish(); - android.os.Process.killProcess(android.os.Process.myPid()); - System.exit(0); - } - - private int dp2px(final float dpValue) { - final float scale = Resources.getSystem().getDisplayMetrics().density; - return (int) (dpValue * scale + 0.5f); - } - - @Override - public boolean onMenuItemClick(MenuItem item) { - switch (item.getItemId()) { - case MENUITEM_COPY: - ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - cm.setPrimaryClip(ClipData.newPlainText(getPackageName(), mLog)); - Toast.makeText(getApplication(), "The text is copied.", Toast.LENGTH_SHORT).show(); - break; - case MENUITEM_RESTART: - AppCrashSafetyWire.getInstance().resumeToMaximumImmediately(); - restart(); - break; - } - return false; - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - menu.add(0, MENUITEM_COPY, 0, "Copy").setOnMenuItemClickListener(this) - .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); - menu.add(0, MENUITEM_RESTART, 0, "Restart").setOnMenuItemClickListener(this) - .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); - return true; - } - } + /** + * 创建 ActionBar 菜单(添加复制、重启项) + * @param menu 菜单容器 + * @return true:显示菜单 + */ + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // 添加「复制」菜单:有空间时显示在 ActionBar,否则放入溢出菜单 + menu.add(0, MENUITEM_COPY, 0, "Copy") + .setOnMenuItemClickListener(this) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + // 添加「重启」菜单:同上 + menu.add(0, MENUITEM_RESTART, 0, "Restart") + .setOnMenuItemClickListener(this) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + return true; + } + } } diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashReportView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashReportView.java index fe68a7be..cb34e7b1 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashReportView.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashReportView.java @@ -1,9 +1,10 @@ package cc.winboll.studio.libappbase; /** - * @Author ZhanGSKen - * @Date 2025/02/11 20:18:30 - * @Describe 应用崩溃报告视图 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/11 20:21 + * @Describe 全局崩溃报告视图控件 + * 用于展示应用崩溃信息,包含顶部工具栏和崩溃日志文本区域,支持自定义配色 */ import android.content.Context; import android.content.res.TypedArray; @@ -20,119 +21,289 @@ import cc.winboll.studio.libappbase.R; public class GlobalCrashReportView extends LinearLayout { - public static final String TAG = "GlobalCrashReportView"; + // 日志标签 + public static final String TAG = "GlobalCrashReportView"; - Context mContext; - Toolbar mToolbar; - int colorTittle; - int colorTittleBackground; - int colorText; - int colorTextBackground; - TextView mtvReport; + // 上下文对象 + private Context mContext; + // 顶部工具栏(标题栏) + private Toolbar mToolbar; + // 标题文字颜色 + private int mTitleColor; + // 标题栏背景颜色 + private int mTitleBackgroundColor; + // 日志文本颜色 + private int mTextColor; + // 日志区域背景颜色 + private int mTextBackgroundColor; + // 崩溃日志显示文本控件 + private TextView mTvReport; - public GlobalCrashReportView(Context context) { - super(context); - mContext = context; - //initView(); - } + /** + * 构造方法:仅上下文 + * @param context 上下文 + */ + public GlobalCrashReportView(Context context) { + super(context); + mContext = context; + // 初始化默认配置(无自定义属性) + initDefaultConfig(); + } - public GlobalCrashReportView(Context context, AttributeSet attrs) { - super(context, attrs); - mContext = context; - initView(attrs); - } + /** + * 构造方法:上下文 + 自定义属性 + * @param context 上下文 + * @param attrs 自定义属性集合 + */ + public GlobalCrashReportView(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + // 初始化视图(解析自定义属性) + initView(attrs); + } - public GlobalCrashReportView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - mContext = context; - //initView(); - } + /** + * 构造方法:上下文 + 自定义属性 + 样式属性 + * @param context 上下文 + * @param attrs 自定义属性集合 + * @param defStyleAttr 样式属性 + */ + public GlobalCrashReportView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + mContext = context; + // 初始化视图(解析自定义属性) + initView(attrs); + } - public GlobalCrashReportView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - mContext = context; - //initView(); - } + /** + * 构造方法:上下文 + 自定义属性 + 样式属性 + 样式资源 + * @param context 上下文 + * @param attrs 自定义属性集合 + * @param defStyleAttr 样式属性 + * @param defStyleRes 样式资源 + */ + public GlobalCrashReportView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + mContext = context; + // 初始化视图(解析自定义属性) + initView(attrs); + } - public void setColorTittle(int colorTittle) { - this.colorTittle = colorTittle; - } + /** + * 设置标题文字颜色 + * @param titleColor 颜色值(如 Color.WHITE 或 #FFFFFF) + */ + public void setTitleColor(int titleColor) { + this.mTitleColor = titleColor; + // 实时更新工具栏标题颜色 + if (mToolbar != null) { + mToolbar.setTitleTextColor(titleColor); + mToolbar.setSubtitleTextColor(titleColor); + } + } - public int getColorTittle() { - return colorTittle; - } + /** + * 获取标题文字颜色 + * @return 标题文字颜色值 + */ + public int getTitleColor() { + return mTitleColor; + } - public void setColorTittleBackground(int colorTittleBackground) { - this.colorTittleBackground = colorTittleBackground; - } + /** + * 设置标题栏背景颜色 + * @param titleBackgroundColor 颜色值(如 Color.BLACK 或 #000000) + */ + public void setTitleBackgroundColor(int titleBackgroundColor) { + this.mTitleBackgroundColor = titleBackgroundColor; + // 实时更新工具栏背景颜色 + if (mToolbar != null) { + mToolbar.setBackgroundColor(titleBackgroundColor); + } + } - public int getColorTittleBackground() { - return colorTittleBackground; - } + /** + * 获取标题栏背景颜色 + * @return 标题栏背景颜色值 + */ + public int getTitleBackgroundColor() { + return mTitleBackgroundColor; + } - public void setColorText(int colorText) { - this.colorText = colorText; - } + /** + * 设置日志文本颜色 + * @param textColor 颜色值(如 Color.BLACK 或 #000000) + */ + public void setTextColor(int textColor) { + this.mTextColor = textColor; + // 实时更新日志文本颜色 + if (mTvReport != null) { + mTvReport.setTextColor(textColor); + } + } - public int getColorText() { - return colorText; - } + /** + * 获取日志文本颜色 + * @return 日志文本颜色值 + */ + public int getTextColor() { + return mTextColor; + } - public void setColorTextBackground(int colorTextBackground) { - this.colorTextBackground = colorTextBackground; - } + /** + * 设置日志区域背景颜色 + * @param textBackgroundColor 颜色值(如 Color.WHITE 或 #FFFFFF) + */ + public void setTextBackgroundColor(int textBackgroundColor) { + this.mTextBackgroundColor = textBackgroundColor; + // 实时更新日志区域和主布局背景颜色 + if (mTvReport != null) { + mTvReport.setBackgroundColor(textBackgroundColor); + } + setBackgroundColor(textBackgroundColor); + } - public int getColorTextBackground() { - return colorTextBackground; - } + /** + * 获取日志区域背景颜色 + * @return 日志区域背景颜色值 + */ + public int getTextBackgroundColor() { + return mTextBackgroundColor; + } - void initView(AttributeSet attrs) { - TypedArray a = mContext.obtainStyledAttributes(attrs, R.styleable.GlobalCrashActivity, R.attr.themeGlobalCrashActivity, 0); - this.colorTittle = a.getColor(R.styleable.GlobalCrashActivity_colorTittle, Color.WHITE); - this.colorTittleBackground = a.getColor(R.styleable.GlobalCrashActivity_colorTittleBackgound, Color.BLACK); - this.colorText = a.getColor(R.styleable.GlobalCrashActivity_colorText, Color.BLACK); - this.colorTextBackground = a.getColor(R.styleable.GlobalCrashActivity_colorTextBackgound, Color.WHITE); - // 返回一个绑定资源结束的信号给资源 - a.recycle(); + /** + * 初始化默认配置(无自定义属性时使用) + */ + private void initDefaultConfig() { + // 设置默认配色 + mTitleColor = Color.WHITE; + mTitleBackgroundColor = Color.BLACK; + mTextColor = Color.BLACK; + mTextBackgroundColor = Color.WHITE; + // 加载布局 + inflateView(); + // 初始化控件样式 + initWidgetStyle(); + } - /*this.colorTittle = Color.WHITE; - this.colorTittleBackground = Color.BLACK; - this.colorText = Color.BLACK; - this.colorTextBackground = Color.WHITE; - */ - - inflate(mContext, R.layout.view_globalcrashreport, this); + /** + * 初始化视图(解析自定义属性 + 加载布局 + 设置样式) + * @param attrs 自定义属性集合 + */ + private void initView(AttributeSet attrs) { + // 解析自定义属性(关联 attrs.xml 中的 GlobalCrashActivity 样式) + TypedArray typedArray = mContext.obtainStyledAttributes( + attrs, + R.styleable.GlobalCrashActivity, + R.attr.themeGlobalCrashActivity, + 0 + ); - LinearLayout llMain = findViewById(R.id.viewglobalcrashreportLinearLayout1); - llMain.setBackgroundColor(this.colorTextBackground); - mToolbar = findViewById(R.id.viewglobalcrashreportToolbar1); - mToolbar.setBackgroundColor(this.colorTittleBackground); - mToolbar.setTitleTextColor(this.colorTittle); - mToolbar.setSubtitleTextColor(this.colorTittle); - mtvReport = findViewById(R.id.viewglobalcrashreportTextView1); - mtvReport.setTextColor(this.colorText); - mtvReport.setBackgroundColor(this.colorTextBackground); - } + // 读取自定义属性值(无设置时使用默认值) + mTitleColor = typedArray.getColor( + R.styleable.GlobalCrashActivity_colorTittle, + Color.WHITE + ); + mTitleBackgroundColor = typedArray.getColor( + R.styleable.GlobalCrashActivity_colorTittleBackgound, // 注:原拼写错误(Backgound→Background),保持与 attrs.xml 一致 + Color.BLACK + ); + mTextColor = typedArray.getColor( + R.styleable.GlobalCrashActivity_colorText, + Color.BLACK + ); + mTextBackgroundColor = typedArray.getColor( + R.styleable.GlobalCrashActivity_colorTextBackgound, // 注:原拼写错误,保持与 attrs.xml 一致 + Color.WHITE + ); - public void setReport(String report) { - mtvReport.setText(report); - } + // 回收 TypedArray,避免内存泄漏 + typedArray.recycle(); - public Toolbar getToolbar() { - return mToolbar; - } + // 加载布局文件 + inflateView(); + // 初始化控件样式 + initWidgetStyle(); + } - // - // 更新菜单文字风格 - // - public void updateMenuStyle() { - // 设置菜单文本颜色 - Menu menu = mToolbar.getMenu(); - for (int i = 0; i < menu.size(); i++) { - MenuItem item = menu.getItem(i); - SpannableString spanString = new SpannableString(item.getTitle().toString()); - spanString.setSpan(new ForegroundColorSpan(this.colorTittle), 0, spanString.length(), 0); - item.setTitle(spanString); - } - } + /** + * 加载布局文件 + */ + private void inflateView() { + // 加载自定义布局(R.layout.view_globalcrashreport) + inflate(mContext, R.layout.view_globalcrashreport, this); + // 绑定控件 + mToolbar = findViewById(R.id.viewglobalcrashreportToolbar1); + mTvReport = findViewById(R.id.viewglobalcrashreportTextView1); + } + + /** + * 初始化控件样式(设置配色和基础属性) + */ + private void initWidgetStyle() { + // 设置主布局背景颜色 + setBackgroundColor(mTextBackgroundColor); + + // 配置工具栏样式 + if (mToolbar != null) { + mToolbar.setBackgroundColor(mTitleBackgroundColor); + mToolbar.setTitleTextColor(mTitleColor); + mToolbar.setSubtitleTextColor(mTitleColor); + } + + // 配置日志文本控件样式 + if (mTvReport != null) { + mTvReport.setTextColor(mTextColor); + mTvReport.setBackgroundColor(mTextBackgroundColor); + // 可选:设置日志文本换行方式(默认已换行,此处增强可读性) + mTvReport.setSingleLine(false); + mTvReport.setHorizontallyScrolling(false); + } + } + + /** + * 设置崩溃报告内容到文本控件 + * @param report 崩溃日志字符串(通常包含异常信息、调用栈等) + */ + public void setReport(String report) { + if (mTvReport != null) { + mTvReport.setText(report); + } + } + + /** + * 获取顶部工具栏对象(用于外部设置标题、添加菜单等) + * @return Toolbar 实例 + */ + public Toolbar getToolbar() { + return mToolbar; + } + + /** + * 更新工具栏菜单文字颜色(与标题颜色保持一致) + * 需在菜单加载完成后调用(如 Toolbar 加载菜单后) + */ + public void updateMenuStyle() { + if (mToolbar == null) return; + + // 获取工具栏菜单 + Menu menu = mToolbar.getMenu(); + if (menu == null || menu.size() == 0) return; + + // 遍历所有菜单项,设置文字颜色 + for (int i = 0; i < menu.size(); i++) { + MenuItem menuItem = menu.getItem(i); + String title = menuItem.getTitle().toString(); + // 使用 SpannableString 设置文字颜色 + SpannableString spanString = new SpannableString(title); + spanString.setSpan( + new ForegroundColorSpan(mTitleColor), + 0, + spanString.length(), + 0 // Spannable.SPAN_INCLUSIVE_EXCLUSIVE(默认值,包含起始位置,不包含结束位置) + ); + menuItem.setTitle(spanString); + } + } } + diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/HorizontalListView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/HorizontalListView.java index bcd5d1f3..4881a259 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/HorizontalListView.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/HorizontalListView.java @@ -1,129 +1,240 @@ package cc.winboll.studio.libappbase; /** - * @Author ZhanGSKen - * @Date 2025/03/12 12:29:01 - * @Describe 水平布局的 ListView + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/11 20:26 + * @Describe 水平滚动 ListView 控件 + * 继承自 ListView,重写布局和测量逻辑,实现子项水平排列和滚动,替代默认垂直布局 */ import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.widget.ListView; import android.widget.Scroller; -import cc.winboll.studio.libappbase.LogUtils; public class HorizontalListView extends ListView { - public static final String TAG = "HorizontalListView"; - private int verticalOffset = 0; - private Scroller scroller; - private int totalWidth; + /** 日志标签,用于当前控件的日志输出标识 */ + public static final String TAG = "HorizontalListView"; - public HorizontalListView(Context context) { - super(context); - init(); - } + /** 子项垂直偏移量(用于调整子项在垂直方向的位置,默认 0) */ + private int mVerticalOffset = 0; + /** 平滑滚动控制器(用于实现水平方向的平滑滚动动画) */ + private Scroller mScroller; + /** 所有子项总宽度(包含内边距),用于计算滚动范围 */ + private int mTotalWidth; - public HorizontalListView(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } + /** + * 构造方法:仅上下文 + * @param context 上下文(Activity/Fragment) + */ + public HorizontalListView(Context context) { + super(context); + init(); + } - public HorizontalListView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - init(); - } + /** + * 构造方法:上下文 + 自定义属性 + * @param context 上下文 + * @param attrs 自定义属性集合(如布局文件中设置的属性) + */ + public HorizontalListView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } - private void init() { - scroller = new Scroller(getContext()); - setHorizontalScrollBarEnabled(true); - setVerticalScrollBarEnabled(false); - } + /** + * 构造方法:上下文 + 自定义属性 + 样式属性 + * @param context 上下文 + * @param attrs 自定义属性集合 + * @param defStyle 样式属性(如系统默认样式) + */ + public HorizontalListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } - public void setVerticalOffset(int verticalOffset) { - this.verticalOffset = verticalOffset; - } + /** + * 初始化控件配置 + * 初始化滚动控制器,设置滚动条显示状态 + */ + private void init() { + // 初始化平滑滚动器(上下文为当前控件所在上下文) + mScroller = new Scroller(getContext()); + // 启用水平滚动条(默认显示) + setHorizontalScrollBarEnabled(true); + // 禁用垂直滚动条(水平列表无需垂直滚动) + setVerticalScrollBarEnabled(false); + } - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - super.onLayout(changed, l, t, r, b); - int childCount = getChildCount(); - int left = getPaddingLeft(); - int viewHeight = getMeasuredHeight() - getPaddingTop() - getPaddingBottom(); - totalWidth = left; + /** + * 设置子项垂直偏移量 + * 用于整体调整所有子项在垂直方向的位置(如居中、偏移) + * @param verticalOffset 垂直偏移像素值(正数向下偏移,负数向上偏移) + */ + public void setVerticalOffset(int verticalOffset) { + this.mVerticalOffset = verticalOffset; + } - for (int i = 0; i < childCount; i++) { - View child = getChildAt(i); - int width = child.getMeasuredWidth(); - int height = child.getMeasuredHeight(); - child.layout(left, verticalOffset, left + width, verticalOffset + height); - left += width; - } - totalWidth = left + getPaddingRight(); - } + /** + * 重写布局方法:实现子项水平排列 + * 遍历所有子项,按水平方向依次布局(左对齐,叠加排列) + * @param changed 布局是否发生变化(true:首次布局或尺寸变化;false:重绘) + * @param l 控件左边界坐标 + * @param t 控件上边界坐标 + * @param r 控件右边界坐标 + * @param b 控件下边界坐标 + */ + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); // 执行父类布局逻辑(确保基础配置生效) - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST); - int newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST); - super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec); - } + int childCount = getChildCount(); // 获取当前可见子项数量 + int left = getPaddingLeft(); // 子项起始左坐标(包含控件左内边距) + // 控件可用高度(总高度 - 上下内边距) + int viewHeight = getMeasuredHeight() - getPaddingTop() - getPaddingBottom(); + mTotalWidth = left; // 初始化总宽度为左内边距 - @Override - public void computeScroll() { - if (scroller.computeScrollOffset()) { - scrollTo(scroller.getCurrX(), scroller.getCurrY()); - postInvalidate(); - } - } + // 遍历子项,水平排列 + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); // 获取当前子项 + int childWidth = child.getMeasuredWidth(); // 子项测量宽度 + int childHeight = child.getMeasuredHeight(); // 子项测量高度 - public void smoothScrollTo(int x, int y) { - int dx = x - getScrollX(); - int dy = y - getScrollY(); - scroller.startScroll(getScrollX(), getScrollY(), dx, dy, 300); // 300ms平滑动画 - invalidate(); - } + // 布局子项:水平方向从 left 开始,垂直方向偏移 mVerticalOffset + child.layout( + left, // 子项左边界 + mVerticalOffset, // 子项上边界(带垂直偏移) + left + childWidth, // 子项右边界(左 + 宽度) + mVerticalOffset + childHeight // 子项下边界(偏移 + 高度) + ); - @Override - public int computeHorizontalScrollRange() { - return totalWidth; - } + left += childWidth; // 更新下一个子项的起始左坐标 + } - @Override - public int computeHorizontalScrollOffset() { - return getScrollX(); - } + // 计算总宽度(所有子项宽度 + 左右内边距) + mTotalWidth = left + getPaddingRight(); + } - @Override - public int computeHorizontalScrollExtent() { - return getWidth(); - } + /** + * 重写测量方法:设置控件测量规则 + * 水平方向:允许无限宽度(适应所有子项总宽度);垂直方向:自适应内容高度 + * @param widthMeasureSpec 父控件传递的宽度测量规格 + * @param heightMeasureSpec 父控件传递的高度测量规格 + */ + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // 重写宽度测量规则:最大值(Integer.MAX_VALUE >> 2 避免溢出),自适应内容 + int newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST); + // 重写高度测量规则:同上,自适应子项高度 + int newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST); - public void scrollToItem(int position) { - if (position < 0 || position >= getChildCount()) { - LogUtils.d(TAG, "无效的position: " + position); - return; - } + // 执行父类测量逻辑(使用重写后的测量规格) + super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec); + } - View targetView = getChildAt(position); - int targetLeft = targetView.getLeft(); - int scrollX = targetLeft - getPaddingLeft(); + /** + * 重写滚动计算方法:实现平滑滚动 + * 配合 Scroller 实现水平方向的平滑滚动动画(需在滚动时调用 invalidate() 触发) + */ + @Override + public void computeScroll() { + // 判断滚动是否正在进行(Scroller 计算当前滚动位置) + if (mScroller.computeScrollOffset()) { + // 滚动到当前计算的位置(x 轴水平滚动,y 轴固定 0) + scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); + // 触发重绘,持续更新滚动状态 + postInvalidate(); + } + } - // 修正最大滚动范围计算 - int maxScrollX = totalWidth; - scrollX = Math.max(0, Math.min(scrollX, maxScrollX)); + /** + * 平滑滚动到指定坐标 + * 基于 Scroller 实现水平方向的平滑滚动(300ms 动画时长) + * @param x 目标 x 轴坐标(水平滚动位置) + * @param y 目标 y 轴坐标(固定 0,无需垂直滚动) + */ + public void smoothScrollTo(int x, int y) { + // 计算滚动距离(目标坐标 - 当前滚动坐标) + int dx = x - getScrollX(); + int dy = y - getScrollY(); - // 强制重新布局和绘制 - requestLayout(); - invalidateViews(); - smoothScrollTo(scrollX, 0); - LogUtils.d(TAG, String.format("滚动到position: %d, scrollX: %d computeHorizontalScrollRange() %d", position, scrollX, computeHorizontalScrollRange())); - } - - public void resetScrollToStart() { - // 强制重新布局和绘制 - requestLayout(); - invalidateViews(); - smoothScrollTo(0, 0); - } + // 启动平滑滚动:起始坐标(当前滚动位置)、滚动距离、动画时长(300ms) + mScroller.startScroll(getScrollX(), getScrollY(), dx, dy, 300); + // 触发重绘,启动滚动动画 + invalidate(); + } + + /** + * 计算水平滚动总范围(用于滚动条显示比例) + * @return 滚动总宽度(所有子项总宽度 + 内边距) + */ + @Override + public int computeHorizontalScrollRange() { + return mTotalWidth; + } + + /** + * 计算当前水平滚动偏移量(用于滚动条位置) + * @return 当前 x 轴滚动坐标 + */ + @Override + public int computeHorizontalScrollOffset() { + return getScrollX(); + } + + /** + * 计算水平滚动可视范围(用于滚动条大小) + * @return 控件可见宽度(当前显示区域宽度) + */ + @Override + public int computeHorizontalScrollExtent() { + return getWidth(); + } + + /** + * 滚动到指定位置的子项(水平方向) + * 定位目标子项,计算滚动坐标,执行平滑滚动 + * @param position 子项索引(从 0 开始,仅当前可见子项有效) + */ + public void scrollToItem(int position) { + // 校验索引有效性(避免数组越界) + if (position < 0 || position >= getChildCount()) { + LogUtils.d(TAG, "无效的子项索引: " + position); + return; + } + + View targetView = getChildAt(position); // 获取目标子项 + int targetLeft = targetView.getLeft(); // 目标子项左边界坐标 + // 计算目标滚动坐标(子项左边界 - 控件左内边距,确保子项左对齐显示) + int scrollX = targetLeft - getPaddingLeft(); + + // 修正滚动范围(避免超出总宽度或小于 0) + int maxScrollX = mTotalWidth - getWidth(); // 最大滚动坐标(总宽度 - 控件宽度) + scrollX = Math.max(0, Math.min(scrollX, maxScrollX)); + + // 强制重新布局和绘制(确保子项位置正确) + requestLayout(); + invalidateViews(); + // 平滑滚动到目标坐标 + smoothScrollTo(scrollX, 0); + + // 打印滚动日志(调试用) + LogUtils.d(TAG, String.format( + "滚动到子项索引: %d, 目标滚动X: %d, 总滚动范围: %d", + position, scrollX, computeHorizontalScrollRange() + )); + } + + /** + * 重置滚动到起始位置(最左侧) + * 强制重新布局后,平滑滚动到 x=0 坐标 + */ + public void resetScrollToStart() { + // 强制重新布局和绘制(确保滚动位置准确) + requestLayout(); + invalidateViews(); + // 平滑滚动到最左侧(x=0,y=0) + smoothScrollTo(0, 0); + } } diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogActivity.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogActivity.java index 08392428..53e80ba0 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogActivity.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogActivity.java @@ -1,9 +1,10 @@ package cc.winboll.studio.libappbase; /** - * @Author ZhanGSKen - * @Date 2025/03/25 20:34:47 - * @Describe 应用日志窗口 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/11 20:29 + * @Describe 应用日志展示 Activity + * 用于单独启动窗口展示应用运行日志,依赖 LogView 控件实现日志加载与显示 */ import android.app.Activity; import android.content.Context; @@ -14,33 +15,51 @@ import cc.winboll.studio.libappbase.R; public class LogActivity extends Activity { + /** 日志标签,用于当前 Activity 的日志输出标识 */ public static final String TAG = "LogActivity"; - LogView mLogView; + /** 日志展示控件(用于加载和显示应用日志) */ + private LogView mLogView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + // 设置布局文件(包含 LogView 控件) setContentView(R.layout.activity_log); - //ToastUtils.show("LogActivity onCreate"); + // 绑定布局中的 LogView 控件 mLogView = findViewById(R.id.logview); + // 启动 LogView 日志加载(如实时刷新日志内容) mLogView.start(); } @Override protected void onResume() { super.onResume(); + // 恢复 Activity 时重新启动 LogView(确保日志持续更新) mLogView.start(); } - public static void startLogActivity(Context context) { - Intent intent = new Intent(context, LogActivity.class); - // 打开多任务窗口 + /** + * 启动日志 Activity 的静态方法(外部调用入口) + * 配置 Intent 标志,以多任务/分屏模式启动,避免与主应用任务栈冲突 + * @param context 上下文(Activity/Fragment),用于启动 Activity + */ + public static void startLogActivity(Context context) { + // 创建启动当前 Activity 的 Intent + Intent intent = new Intent(context, LogActivity.class); + + // 添加 Intent 标志:支持分屏/多窗口模式(API 24+) intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); + // 添加 Intent 标志:创建新任务栈(避免并入调用者任务栈) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + // 添加 Intent 标志:标记为新文档(多任务窗口中独立显示) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + // 添加 Intent 标志:允许创建多个任务实例(支持多次启动独立窗口) intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + + // 启动 Activity context.startActivity(intent); - } + } } +