docs: add crash diagnostics and startup safeguards

This commit is contained in:
clz
2026-08-04 21:17:45 +08:00
parent ffb55e29cc
commit 2134f54010
39 changed files with 1305 additions and 18 deletions
+6
View File
@@ -0,0 +1,6 @@
/node_modules
/oh_modules
/.preview
/build
/.cxx
/.test
@@ -0,0 +1,42 @@
{
"apiType": "stageMode",
"buildOption": {
"externalNativeOptions": {
"path": "./src/main/cpp/CMakeLists.txt",
"abiFilters": [
"arm64-v8a"
],
"targets": [
"entry"
]
},
"resOptions": {
"copyCodeResource": {
"enable": false
}
}
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": false,
"files": [
"./obfuscation-rules.txt"
]
}
}
}
},
],
"targets": [
{
"name": "default"
},
{
"name": "ohosTest",
}
]
}
+7
View File
@@ -0,0 +1,7 @@
// @ts-nocheck – HarmonyOS template file, only used when copied into a DevEco project
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
}
@@ -0,0 +1,23 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope
-enable-property-obfuscation
-enable-toplevel-obfuscation
-enable-filename-obfuscation
-enable-export-obfuscation
@@ -0,0 +1,9 @@
{
"name": "entry",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "",
"author": "",
"license": "",
"dependencies": {}
}
@@ -0,0 +1,5 @@
cmake_minimum_required(VERSION 3.5.0)
project(entry)
add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC libace_napi.z.so)
@@ -0,0 +1,19 @@
#include <cstdlib>
#include "napi/native_api.h"
static napi_value SimulateCppCrash(napi_env env, napi_callback_info info)
{
std::abort();
return nullptr;
}
static napi_value Init(napi_env env, napi_value exports)
{
napi_property_descriptor descriptors[] = {
{"simulateCppCrash", nullptr, SimulateCppCrash, nullptr, nullptr, nullptr, napi_default, nullptr}
};
napi_define_properties(env, exports, 1, descriptors);
return exports;
}
NAPI_MODULE(entry, Init)
@@ -0,0 +1 @@
export const simulateCppCrash: () => void;
@@ -0,0 +1,226 @@
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { hiAppEvent, hilog } from '@kit.PerformanceAnalysisKit';
export class CrashDiagnostics {
private static readonly STORE_NAME: string = 'debug_crash_diagnostics';
private static readonly RECORD_KEY: string = 'recent_hiappevent_crash_records';
private static readonly DISMISSED_RECORD_KEY: string = 'dismissed_hiappevent_crash_id';
private static readonly WATCHER_NAME: string = 'debugCrashWatcher';
private static readonly MAX_RECORD_COUNT: number = 10;
private static readonly LOG_DOMAIN: number = 0xD001;
private static store: preferences.Preferences | undefined = undefined;
private static recordsUpdatedListener: (() => void) | undefined = undefined;
static initialize(context: common.UIAbilityContext): void {
try {
const options: preferences.Options = { name: CrashDiagnostics.STORE_NAME };
CrashDiagnostics.store = preferences.getPreferencesSync(context, options);
CrashDiagnostics.collectPreviousCrash();
} catch (err) {
CrashDiagnostics.logStorageFailure('Failed to initialize diagnostic storage.', err);
}
}
static recordCaughtError(error: Error, pageName: string): void {
const stack: string = error.stack ?? '无可用堆栈';
const record: string = `时间:${new Date().getTime()}\n页面:${pageName}\n类型:${error.name}\n信息:${error.message}\n堆栈:\n${stack}`;
// 可捕获异常只输出日志,不混入系统 APP_CRASH 历史。
hilog.error(CrashDiagnostics.LOG_DOMAIN, 'CrashDiagnostics', '%{public}s', record);
}
static setRecordsUpdatedListener(listener: () => void): void {
CrashDiagnostics.recordsUpdatedListener = listener;
}
static clearRecordsUpdatedListener(): void {
CrashDiagnostics.recordsUpdatedListener = undefined;
}
static getLastRecord(): string {
const records: Array<string> = CrashDiagnostics.getRecentRecords();
return records.length > 0 ? records[0] : '';
}
static shouldShowLastCrashDialog(): boolean {
const latestRecord: string = CrashDiagnostics.getLastRecord();
const store: preferences.Preferences | undefined = CrashDiagnostics.store;
if (latestRecord === '' || store === undefined) {
return false;
}
try {
const dismissedId: string = store.getSync(CrashDiagnostics.DISMISSED_RECORD_KEY, '') as string;
return dismissedId !== CrashDiagnostics.getRecordId(latestRecord);
} catch (err) {
CrashDiagnostics.logStorageFailure('Failed to read dismissed crash ID.', err);
return true;
}
}
static dismissLastCrashUntilNew(): void {
const latestRecord: string = CrashDiagnostics.getLastRecord();
const store: preferences.Preferences | undefined = CrashDiagnostics.store;
if (latestRecord === '' || store === undefined) {
return;
}
try {
store.putSync(CrashDiagnostics.DISMISSED_RECORD_KEY, CrashDiagnostics.getRecordId(latestRecord));
store.flushSync();
} catch (err) {
CrashDiagnostics.logStorageFailure('Failed to save dismissed crash ID.', err);
}
}
static getRecentRecords(): Array<string> {
const store: preferences.Preferences | undefined = CrashDiagnostics.store;
if (store === undefined) {
return [];
}
try {
const json: string = store.getSync(CrashDiagnostics.RECORD_KEY, '[]') as string;
return JSON.parse(json) as Array<string>;
} catch (err) {
CrashDiagnostics.logStorageFailure('Failed to read diagnostic record.', err);
return [];
}
}
static clearLastRecord(): void {
const store: preferences.Preferences | undefined = CrashDiagnostics.store;
if (store === undefined) {
return;
}
try {
store.deleteSync(CrashDiagnostics.RECORD_KEY);
store.deleteSync(CrashDiagnostics.DISMISSED_RECORD_KEY);
store.flushSync();
} catch (err) {
CrashDiagnostics.logStorageFailure('Failed to clear diagnostic record.', err);
}
}
private static collectPreviousCrash(): void {
// 使用 onReceive 模式订阅崩溃事件。
// 官方文档:应用未主动捕获崩溃异常时,系统处理崩溃后应用退出;
// 应用下次启动时,HiAppEvent 将崩溃事件通过 onReceive 回调上报给已注册的监听。
const watcher: hiAppEvent.Watcher = {
name: CrashDiagnostics.WATCHER_NAME,
appEventFilters: [{
domain: hiAppEvent.domain.OS,
names: [hiAppEvent.event.APP_CRASH]
}],
onReceive: (domain: string, appEventGroups: Array<hiAppEvent.AppEventGroup>) => {
hilog.info(CrashDiagnostics.LOG_DOMAIN, 'CrashDiagnostics',
'onReceive domain=%{public}s, groups=%{public}d', domain, appEventGroups.length);
CrashDiagnostics.processAppEventGroups(appEventGroups);
}
};
try {
hiAppEvent.addWatcher(watcher);
} catch (err) {
CrashDiagnostics.logStorageFailure('Failed to add APP_CRASH watcher.', err);
}
}
private static processAppEventGroups(appEventGroups: Array<hiAppEvent.AppEventGroup>): void {
const newRecords: Array<string> = [];
for (let groupIndex: number = 0; groupIndex < appEventGroups.length; groupIndex++) {
const group: hiAppEvent.AppEventGroup = appEventGroups[groupIndex];
for (let infoIndex: number = 0; infoIndex < group.appEventInfos.length; infoIndex++) {
const info: hiAppEvent.AppEventInfo = group.appEventInfos[infoIndex];
const record: string = CrashDiagnostics.formatCrashEventInfo(info);
newRecords.push(record);
}
}
if (newRecords.length === 0) {
hilog.warn(CrashDiagnostics.LOG_DOMAIN, 'CrashDiagnostics',
'onReceive delivered empty crash event groups.');
}
CrashDiagnostics.cacheHiAppEventRecords(newRecords);
}
private static formatCrashEventInfo(info: hiAppEvent.AppEventInfo): string {
// ArkTS: info.params 类型为 object,无索引签名,通过 JSON 序列化后再解析以支持字段访问
const paramsStr: string = JSON.stringify(info.params);
const p: Record<string, Object> = JSON.parse(paramsStr) as Record<string, Object>;
const crashType: string = (p['crash_type'] as string) ?? 'Unknown';
const bundleName: string = (p['bundle_name'] as string) ?? '';
const uuid: string = (p['uuid'] as string) ?? '';
const pid: Object | undefined = p['pid'];
const time: Object | undefined = p['time'];
const foreground: Object | undefined = p['foreground'];
const exception: Object | undefined = p['exception'];
let record: string = `来源:HiAppEvent / onReceive\n`;
record += `事件:APP_CRASH (${crashType})\n`;
record += `时间:${time ?? '未知'}\n`;
record += `包名:${bundleName}\n`;
record += `UUID:${uuid}\n`;
record += `PID:${pid ?? '未知'}\n`;
record += `前台:${foreground ?? '未知'}\n`;
if (exception !== undefined && exception !== '') {
record += `异常信息:${exception}\n`;
}
record += `系统参数:\n${paramsStr}`;
return record;
}
private static cacheHiAppEventRecords(newRecords: Array<string>): void {
const store: preferences.Preferences | undefined = CrashDiagnostics.store;
if (store === undefined || newRecords.length === 0) {
return;
}
try {
const existingRecords: Array<string> = CrashDiagnostics.getRecentRecords();
const candidates: Array<string> = newRecords.concat(existingRecords);
const recentRecords: Array<string> = [];
for (let index: number = 0; index < candidates.length; index++) {
const candidate: string = candidates[index];
if (recentRecords.indexOf(candidate) === -1) {
recentRecords.push(candidate);
}
if (recentRecords.length === CrashDiagnostics.MAX_RECORD_COUNT) {
break;
}
}
let recordsChanged: boolean = recentRecords.length !== existingRecords.length;
if (!recordsChanged) {
for (let index: number = 0; index < recentRecords.length; index++) {
if (recentRecords[index] !== existingRecords[index]) {
recordsChanged = true;
break;
}
}
}
if (!recordsChanged) {
return;
}
store.putSync(CrashDiagnostics.RECORD_KEY, JSON.stringify(recentRecords));
store.flushSync();
hilog.info(CrashDiagnostics.LOG_DOMAIN, 'CrashDiagnostics',
'Cached %{public}d APP_CRASH records from HiAppEvent.', recentRecords.length);
const listener: (() => void) | undefined = CrashDiagnostics.recordsUpdatedListener;
if (listener !== undefined) {
listener();
}
} catch (err) {
CrashDiagnostics.logStorageFailure('Failed to cache HiAppEvent crash records.', err);
}
}
private static getRecordId(record: string): string {
// 注意:hiAppEvent 的 uuid 是"应用运行时唯一关联ID",同一应用所有崩溃的 uuid 相同,
// 不能用于区分不同崩溃事件。因此对整条记录内容取 hash 作为 ID。
let hash: number = 0;
for (let index: number = 0; index < record.length; index++) {
hash = ((hash << 5) - hash + record.charCodeAt(index)) | 0;
}
return `${record.length}_${hash >>> 0}`;
}
private static logStorageFailure(message: string, err: Object): void {
const detail: string = err instanceof Error ? err.message : 'Unknown error';
hilog.error(CrashDiagnostics.LOG_DOMAIN, 'CrashDiagnostics', '%{public}s %{public}s', message, detail);
}
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AbilityConstant, common, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { CrashDiagnostics } from '../diagnostics/CrashDiagnostics';
const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
CrashDiagnostics.initialize(this.context as common.UIAbilityContext);
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) {
if (err instanceof Error) {
CrashDiagnostics.recordCaughtError(err, 'EntryAbility.onCreate');
}
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
}
onDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
}
onWindowStageDestroy(): void {
// Main window is destroyed, release UI related resources
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
}
onForeground(): void {
// Ability has brought to foreground
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
}
onBackground(): void {
// Ability has back to background
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
const DOMAIN = 0x0000;
export default class EntryBackupAbility extends BackupExtensionAbility {
async onBackup() {
hilog.info(DOMAIN, 'testTag', 'onBackup ok');
await Promise.resolve();
}
async onRestore(bundleVersion: BundleVersion) {
hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
await Promise.resolve();
}
}
@@ -0,0 +1,315 @@
import { common } from '@kit.AbilityKit';
import { zlib } from '@kit.BasicServicesKit';
import { fileIo, picker } from '@kit.CoreFileKit';
import { CrashDiagnostics } from '../diagnostics/CrashDiagnostics';
interface CrashExportPayload {
exportedAt: string;
source: string;
count: number;
records: Array<string>;
}
@Entry
@Component
struct CrashHistory {
@State records: Array<string> = [];
@State exportStatus: string = '';
aboutToAppear(): void {
this.reloadRecords();
}
private reloadRecords(): void {
this.records = CrashDiagnostics.getRecentRecords();
}
private extractBrief(record: string): string {
const match: RegExpMatchArray | null = record.match(/APP_CRASH \(([^)]+)\)/);
const crashType: string = match !== null ? match[1] : 'Unknown';
const timeMatch: RegExpMatchArray | null = record.match(/时间:(\d+)/);
let timeStr: string = '';
if (timeMatch !== null) {
timeStr = new Date(parseInt(timeMatch[1])).toLocaleString();
}
return `${crashType} · ${timeStr !== '' ? timeStr : '时间未知'}`;
}
private showRecordDetail(index: number): void {
const brief: string = this.extractBrief(this.records[index]);
const options: AlertDialogParamWithButtons = {
title: `崩溃详情 #${index + 1}`,
message: `${brief}\n\n${this.records[index]}`,
primaryButton: {
value: '关闭',
action: () => {}
},
secondaryButton: {
value: '返回列表',
action: () => {}
}
};
this.getUIContext().showAlertDialog(options);
}
private clearRecords(): void {
CrashDiagnostics.clearLastRecord();
this.reloadRecords();
this.exportStatus = '已清除全部崩溃记录';
}
private async exportSingleRecord(index: number): Promise<void> {
try {
const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
const timestamp: number = new Date().getTime();
const fileName: string = `crash-record-${index + 1}-${timestamp}.json`;
const documentPicker: picker.DocumentViewPicker = new picker.DocumentViewPicker(context);
const saveOptions: picker.DocumentSaveOptions = new picker.DocumentSaveOptions();
saveOptions.newFileNames = [fileName];
saveOptions.fileSuffixChoices = ['JSON 文件|.json'];
const saveUris: Array<string> = await documentPicker.save(saveOptions);
if (saveUris.length === 0) {
return;
}
const payload: CrashExportPayload = {
exportedAt: new Date(timestamp).toISOString(),
source: 'HiAppEvent / APP_CRASH',
count: 1,
records: [this.records[index]]
};
const reportFile: fileIo.File = fileIo.openSync(saveUris[0],
fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
try {
fileIo.writeSync(reportFile.fd, JSON.stringify(payload, null, 2));
} finally {
fileIo.closeSync(reportFile);
}
this.exportStatus = `已导出记录 #${index + 1}`;
} catch (err) {
const message: string = err instanceof Error ? err.message : '未知错误';
this.exportStatus = `导出失败:${message}`;
}
}
private async exportArchive(): Promise<void> {
if (this.records.length === 0) {
this.exportStatus = '暂无可导出的崩溃记录';
return;
}
let reportPath: string = '';
let archivePath: string = '';
try {
const context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
const timestamp: number = new Date().getTime();
const reportName: string = `crash-records-${timestamp}.json`;
const archiveName: string = `crash-records-${timestamp}.zip`;
const tmpDir: string = context.tempDir;
reportPath = `${tmpDir}/${reportName}`;
archivePath = `${tmpDir}/${archiveName}`;
const payload: CrashExportPayload = {
exportedAt: new Date(timestamp).toISOString(),
source: 'HiAppEvent / APP_CRASH',
count: this.records.length,
records: this.records
};
// 1. 写 JSON 到 tempDir
const reportFile: fileIo.File = fileIo.openSync(reportPath,
fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
try {
fileIo.writeSync(reportFile.fd, JSON.stringify(payload, null, 2));
} finally {
fileIo.closeSync(reportFile);
}
// 2. 压缩为 ZIP
await zlib.compressFile(reportPath, archivePath, {
level: zlib.CompressLevel.COMPRESS_LEVEL_BEST_COMPRESSION
});
// 3. 弹出保存对话框
const documentPicker: picker.DocumentViewPicker = new picker.DocumentViewPicker(context);
const saveOptions: picker.DocumentSaveOptions = new picker.DocumentSaveOptions();
saveOptions.newFileNames = [archiveName];
saveOptions.fileSuffixChoices = ['ZIP 压缩包|.zip'];
const saveUris: Array<string> = await documentPicker.save(saveOptions);
if (saveUris.length === 0) {
this.exportStatus = '已取消导出';
return;
}
// 4. 读 ZIP 字节,写到目标 URI(绕过 copyFile 不兼容 URI 的问题)
const zipFile: fileIo.File = fileIo.openSync(archivePath, fileIo.OpenMode.READ_ONLY);
const stat: fileIo.Stat = fileIo.statSync(archivePath);
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
try {
fileIo.readSync(zipFile.fd, buf);
} finally {
fileIo.closeSync(zipFile);
}
const destFile: fileIo.File = fileIo.openSync(saveUris[0],
fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
try {
fileIo.writeSync(destFile.fd, buf);
} finally {
fileIo.closeSync(destFile);
}
this.exportStatus = `已保存到手机:${archiveName}`;
} catch (err) {
const message: string = err instanceof Error ? err.message : '未知错误';
this.exportStatus = `导出失败:${message}`;
}
}
build() {
Column() {
// 标题栏
Row() {
Button({ type: ButtonType.Circle }) {
Text('←')
.fontSize(22)
.fontColor('#1A1A1A')
}
.width(40)
.height(40)
.backgroundColor('#00FFFFFF')
.onClick(() => {
this.getUIContext().getRouter().back();
})
Text('崩溃记录')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
Text(`${this.records.length}/10`)
.fontSize(14)
.fontColor('#991A1A1A')
}
.width('100%')
.height(56)
.padding({ left: 8, right: 16 })
.backgroundColor('#FFFFFF')
// 操作栏
Row({ space: 12 }) {
Button('导出全部 ZIP')
.layoutWeight(1)
.height(40)
.fontSize(14)
.borderRadius(8)
.enabled(this.records.length > 0)
.onClick(() => {
this.exportArchive();
})
Button('清除全部')
.layoutWeight(1)
.height(40)
.fontSize(14)
.borderRadius(8)
.enabled(this.records.length > 0)
.onClick(() => {
this.clearRecords();
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
.backgroundColor('#FFFFFF')
// 状态提示
if (this.exportStatus !== '') {
Text(this.exportStatus)
.fontSize(13)
.fontColor('#99000000')
.width('100%')
.padding({ left: 16, right: 16, top: 4, bottom: 8 })
.backgroundColor('#FFFFFF')
}
// 内容区
if (this.records.length === 0) {
Column({ space: 12 }) {
Text('暂无崩溃记录')
.fontSize(16)
.fontColor('#99000000')
Text('触发模拟崩溃并重启应用后,记录将在此显示')
.fontSize(13)
.fontColor('#66000000')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1F3F5')
} else {
List({ space: 0 }) {
ForEach(this.records, (_record: string, index: number) => {
ListItem() {
Column() {
Row() {
Column({ space: 4 }) {
Text(`崩溃 #${index + 1}${index === 0 ? '(最新)' : ''}`)
.fontSize(16)
.fontWeight(index === 0 ? FontWeight.Medium : FontWeight.Regular)
Text(this.extractBrief(this.records[index]))
.fontSize(13)
.fontColor('#99000000')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('>')
.fontSize(20)
.fontColor('#99000000')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.onClick(() => {
this.showRecordDetail(index);
})
Divider()
.strokeWidth(0.5)
.color('#1A000000')
.margin({ left: 16 })
Row() {
Blank()
Button('导出')
.fontSize(13)
.type(ButtonType.Normal)
.height(32)
.borderRadius(6)
.onClick(() => {
this.exportSingleRecord(index);
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 10 })
}
.width('100%')
.backgroundColor('#FFFFFF')
}
}, (_record: string, index: number) => index.toString())
}
.width('100%')
.layoutWeight(1)
.backgroundColor('#F1F3F5')
.padding({ top: 8 })
}
}
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
*/
import { CrashDiagnostics } from '../diagnostics/CrashDiagnostics';
import crashNative from 'libentry.so';
const ENABLE_DEBUG_CRASH_DIAGNOSTICS: boolean = true;
let startupCrashDialogShown: boolean = false;
@Entry
@Component
struct Index {
@State statusText: string = '未发现待处理的崩溃记录';
@State recordCount: number = 0;
aboutToAppear(): void {
CrashDiagnostics.setRecordsUpdatedListener(() => {
this.updateRecordStatus();
this.showLastCrashIfNeeded();
});
this.updateRecordStatus();
this.showLastCrashIfNeeded();
}
aboutToDisappear(): void {
CrashDiagnostics.clearRecordsUpdatedListener();
}
private updateRecordStatus(): void {
this.recordCount = CrashDiagnostics.getRecentRecords().length;
this.statusText = this.recordCount === 0 ?
'未发现待处理的崩溃记录' : `已缓存 ${this.recordCount}/10 条崩溃记录`;
}
private showLastCrashIfNeeded(): void {
if (!ENABLE_DEBUG_CRASH_DIAGNOSTICS || startupCrashDialogShown ||
!CrashDiagnostics.shouldShowLastCrashDialog()) {
return;
}
const record: string = CrashDiagnostics.getLastRecord();
if (record === '') {
return;
}
startupCrashDialogShown = true;
const options: AlertDialogParamWithButtons = {
title: '检测到上次异常退出',
message: `${record}\n\n—— 按钮说明 ——\n确认:仅关闭本次弹窗,下次启动仍会提示。\n暂不处理:当前崩溃后续不再提示,检测到新崩溃后才会再次弹出。`,
primaryButton: {
value: '确认',
action: () => {
this.statusText = '当前崩溃未标记处理,下次启动仍会提示';
}
},
secondaryButton: {
value: '暂不处理',
action: () => {
CrashDiagnostics.dismissLastCrashUntilNew();
this.statusText = '当前崩溃不再提示,检测到新崩溃后会再次弹出';
}
}
};
this.getUIContext().showAlertDialog(options);
}
private confirmCppCrashSimulation(): void {
const options: AlertDialogParamWithButtons = {
title: '模拟 Native 崩溃',
message: '将调用 std::abort() 使应用异常退出。\n重启后由 HiAppEvent 读取系统生成的 APP_CRASH 事件。',
primaryButton: {
value: '确认退出',
action: () => {
crashNative.simulateCppCrash();
}
},
secondaryButton: {
value: '取消',
action: () => {
this.statusText = '已取消操作';
}
}
};
this.getUIContext().showAlertDialog(options);
}
build() {
Column({ space: 20 }) {
Text('崩溃诊断调试')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('Native 崩溃信息来源:HiAppEvent / APP_CRASH')
.fontSize(14)
.fontColor('#999999')
Divider()
.strokeWidth(0.5)
Column({ space: 12 }) {
Text(this.statusText)
.fontSize(15)
.fontColor(this.recordCount > 0 ? '#E6A817' : '#666666')
Button('模拟 Native 崩溃(应用将退出)')
.width('100%')
.height(44)
.fontSize(16)
.backgroundColor('#D94838')
.borderRadius(8)
.onClick(() => {
this.confirmCppCrashSimulation();
})
Button(`查看崩溃记录(${this.recordCount}/10)`)
.width('100%')
.height(44)
.fontSize(16)
.borderRadius(8)
.enabled(this.recordCount > 0)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/CrashHistory' });
})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding(24)
}
}
@@ -0,0 +1,50 @@
{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"ohos.want.action.home"
]
}
]
}
],
"extensionAbilities": [
{
"name": "EntryBackupAbility",
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
"type": "backup",
"exported": false,
"metadata": [
{
"name": "ohos.extension.backup",
"resource": "$profile:backup_config"
}
],
}
]
}
}
@@ -0,0 +1,8 @@
{
"color": [
{
"name": "start_window_background",
"value": "#FFFFFF"
}
]
}
@@ -0,0 +1,8 @@
{
"float": [
{
"name": "page_text_font_size",
"value": "50fp"
}
]
}
@@ -0,0 +1,16 @@
{
"string": [
{
"name": "module_desc",
"value": "module description"
},
{
"name": "EntryAbility_desc",
"value": "description"
},
{
"name": "EntryAbility_label",
"value": "CrashDiagnosticsDemo"
}
]
}
@@ -0,0 +1,7 @@
{
"layered-image":
{
"background" : "$media:background",
"foreground" : "$media:foreground"
}
}
@@ -0,0 +1,3 @@
{
"allowToBackupRestore": true
}
@@ -0,0 +1,6 @@
{
"src": [
"pages/Index",
"pages/CrashHistory"
]
}
@@ -0,0 +1,8 @@
{
"color": [
{
"name": "start_window_background",
"value": "#000000"
}
]
}