docs: add crash diagnostics and startup safeguards
This commit is contained in:
@@ -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",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user