docs: add crash diagnostics and startup safeguards
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This repository is an Obsidian-friendly documentation project for HarmonyOS APM analysis.
|
||||
|
||||
- `HarmonyOS APM分析报告_20260731.md` is the main report and source of truth for findings, calculations, and recommendations.
|
||||
- `report-assets/` contains the screenshots referenced by the report. Keep assets close to the section that explains them through relative Markdown links such as ``.
|
||||
- `.obsidian/` stores workspace and appearance settings. Avoid committing personal workspace-state changes unless they are intentionally shared.
|
||||
|
||||
When adding a new report, use a descriptive name with a date (`YYYYMMDD`) and place its images in `report-assets/` or a clearly named report-specific subdirectory.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
There is no compilation step or package-managed test suite. Edit and preview the Markdown in Obsidian or another CommonMark-compatible viewer.
|
||||
|
||||
Useful repository checks:
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
rg 'report-assets/' '*.md'
|
||||
find report-assets -type f | sort
|
||||
```
|
||||
|
||||
Use these to review changed files, inspect asset references, and inventory screenshots before committing. Confirm every image renders and every table remains readable in preview.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use UTF-8 Markdown, ATX headings (`#`, `##`), fenced code blocks with a language tag, and pipe tables with right-aligned numeric columns where appropriate. Keep heading levels sequential and write concise, evidence-based Chinese prose consistent with the existing report. Use backticks for event types, API names, versions, and identifiers.
|
||||
|
||||
Name screenshots with a two-digit sequence and a short lowercase description, for example `08-grafana-native-detail.png`. Do not replace an existing image with unrelated evidence under the same filename.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Validation is manual. Before submitting changes:
|
||||
|
||||
1. Preview the full report and check headings, tables, code blocks, and image captions.
|
||||
2. Recalculate totals and confirm date ranges, versions, and event classifications against the source screenshots.
|
||||
3. Verify all referenced files exist and that no local absolute paths or sensitive operational data were introduced.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History is currently minimal (`first commit`), so use concise, imperative commit subjects such as `docs: clarify OOM crash accounting` or `assets: add native crash evidence`. Keep report and supporting screenshots in the same commit.
|
||||
|
||||
Pull requests should summarize the conclusion changed, identify the data period and source, list added or replaced assets, and call out any unresolved evidence gaps. Include rendered screenshots when formatting changes are substantial and link the relevant issue when one exists.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.example.crashdiagnosticsdemo",
|
||||
"vendor": "example",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "CrashDiagnosticsDemo"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"files": [
|
||||
"**/*.ets"
|
||||
],
|
||||
"ignore": [
|
||||
"**/src/ohosTest/**/*",
|
||||
"**/src/test/**/*",
|
||||
"**/src/mock/**/*",
|
||||
"**/node_modules/**/*",
|
||||
"**/oh_modules/**/*",
|
||||
"**/build/**/*",
|
||||
"**/.preview/**/*"
|
||||
],
|
||||
"ruleSet": [
|
||||
"plugin:@performance/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@security/no-unsafe-aes": "error",
|
||||
"@security/no-unsafe-hash": "error",
|
||||
"@security/no-unsafe-mac": "warn",
|
||||
"@security/no-unsafe-dh": "error",
|
||||
"@security/no-unsafe-dsa": "error",
|
||||
"@security/no-unsafe-ecdsa": "error",
|
||||
"@security/no-unsafe-rsa-encrypt": "error",
|
||||
"@security/no-unsafe-rsa-sign": "error",
|
||||
"@security/no-unsafe-rsa-key": "error",
|
||||
"@security/no-unsafe-dsa-key": "error",
|
||||
"@security/no-unsafe-dh-key": "error",
|
||||
"@security/no-unsafe-3des": "error"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"modelVersion": "6.1.0",
|
||||
"dependencies": {
|
||||
},
|
||||
"execution": {
|
||||
// "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */
|
||||
// "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */
|
||||
// "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */
|
||||
// "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */
|
||||
// "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */
|
||||
// "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */
|
||||
},
|
||||
"logging": {
|
||||
// "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */
|
||||
},
|
||||
"debugging": {
|
||||
// "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */
|
||||
},
|
||||
"nodeOptions": {
|
||||
// "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/
|
||||
// "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// @ts-nocheck – HarmonyOS template file, only used when copied into a DevEco project
|
||||
import { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0",
|
||||
"@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hamock@1.0.0": {
|
||||
"name": "@ohos/hamock",
|
||||
"version": "1.0.0",
|
||||
"integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har",
|
||||
"registryType": "ohpm"
|
||||
},
|
||||
"@ohos/hypium@1.0.25": {
|
||||
"name": "@ohos/hypium",
|
||||
"version": "1.0.25",
|
||||
"integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"modelVersion": "6.1.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.25",
|
||||
"@ohos/hamock": "1.0.0"
|
||||
}
|
||||
}
|
||||
+144
-18
@@ -1,7 +1,6 @@
|
||||
# HarmonyOS APM分析报告
|
||||
# HarmonyOS APM分析报告
|
||||
|
||||
|
||||
# 崩溃问题统计与分析
|
||||
# 崩溃问题
|
||||
## 1. 结论摘要
|
||||
|
||||
最新7日数据中(2026.7.24-2026.7.30):
|
||||
@@ -59,7 +58,7 @@
|
||||
|
||||

|
||||
|
||||
**图2:Grafana最新7日数据。该版本七日共33次,7月30日为17次。
|
||||
**图2:Grafana最新7日数据。该版本七日共33次,7月30日为17次。**
|
||||
|
||||
#### 2.2.3 七日汇总分析
|
||||
|
||||
@@ -186,7 +185,7 @@ Grafana统计“崩溃数量”,但却将OOM同时计入。
|
||||
| 类型 | 华为平台 | Grafana明细 | 备注 |
|
||||
| ------------- | ----: | --------------------------: | -------------------------------------------------------------------------------------------------- |
|
||||
| JS类崩溃 | 3 | 3条`Cannot read property...` | 经过对比两平台数据内容一致 |
|
||||
| CPP/Native类崩溃 | 3 | 1条`unKnowException`属于该类 | 华为平台的CPP_CRASH统计包含APP原因导致的崩溃,和**系统原因**导致的崩溃。<br><br>但是Grafana仅采集到了一条系统原因的CPP_CRASH,确实另外两个CPP_CRASH |
|
||||
| CPP/Native类崩溃 | 3 | 1条`unKnowException`属于该类 | 华为平台的CPP_CRASH统计包含APP原因导致的崩溃,和**系统原因**导致的崩溃。<br><br>但是Grafana仅采集到了一条系统原因的CPP_CRASH,缺失另外两个CPP_CRASH |
|
||||
| **合计** | **6** | **4** | |
|
||||
|
||||
3条JS类事件基本可以对应。`unKnowException`对应其中1条Native事件,但仍有2条CPP_CRASH未在Grafana中以明确崩溃类型呈现。
|
||||
@@ -200,20 +199,35 @@ Grafana统计“崩溃数量”,但却将OOM同时计入。
|
||||
|
||||
最终定性:
|
||||
|
||||
> **Grafana崩溃指标口径过宽,同时Native崩溃采集或展示存在漏报问题,需进一步分析。
|
||||
> **Grafana崩溃指标口径过宽,同时Native崩溃采集或展示存在漏报问题,需进一步分析。**
|
||||
|
||||
|
||||
# 启动耗时采集上报
|
||||
## 4. 线上崩溃预防措施
|
||||
|
||||
### 4.1 Debug阶段崩溃诊断
|
||||
|
||||
在 Debug、测试包或内部环境中增加崩溃诊断弹窗,让开发者能在测试时及时发现异常并获取定位信息。该能力不面向正式用户开放。
|
||||
|
||||
- **进程级崩溃留存:** JS Crash、CPP Crash 会导致当前进程退出,无法保证当场展示弹窗。因此应在应用下次启动后识别并读取上次异常退出的信息。
|
||||
- **本地历史:** 缓存最近 10 条崩溃记录,按最新优先展示。提供独立“崩溃记录”页面,列表展示摘要,点击单条后再查看完整信息。
|
||||
- **启动提示策略:** 应用启动后自动提示最新一条崩溃。“确认”只关闭本次弹窗,下次启动仍提示;“暂不处理”后续不再提示当前崩溃,直到出现新的崩溃记录。
|
||||
- **诊断操作:** 支持清除本地历史,并将崩溃记录导出为压缩包保存到手机,便于附在缺陷单中。
|
||||
|
||||
该能力只在 Debug测试包或内部环境启用;正式包仍应使用线上崩溃平台完成聚合、告警和版本追踪。
|
||||
|
||||
|
||||
|
||||
# 启动耗时
|
||||
|
||||
## 1. 现状
|
||||
|
||||
目前与其他端相比,鸿蒙端APM缺失启动耗时的统计与上报功能,需要上线该功能来监控和分析启动耗时问题。
|
||||
|
||||
## 2. 技术可行性
|
||||
## 2. 可行性分析
|
||||
|
||||
### 2.1 获取应用开始启动的时间
|
||||
|
||||
在鸿蒙开发中,要监听用户手指点击应用图标开始启动的时间,可以通过系统提供的**启动耗时事件**来获取。系统会在用户点击图标时自动打点记录,开发者通过订阅 `HiAppEvent` 的`APP_STARTUP`的事件,即可在参数 `icon_input_time` 拿到这个精确的时间戳。
|
||||
在鸿蒙开发中,要监听用户手指点击应用图标开始启动的时间,可以通过系统提供的启动耗时事件来获取。系统会在**用户点击图标**时自动打点记录,开发者通过订阅 `HiAppEvent` 的 `APP_LAUNCH` 事件,即可在参数 `icon_input_time` 中拿到这个精确的时间戳。
|
||||
|
||||
**示例代码**
|
||||
```typescript
|
||||
@@ -282,20 +296,132 @@ struct HomePage {
|
||||
```
|
||||
|
||||
|
||||
## 3. 开发方案
|
||||
|
||||
### 3.1 首屏渲染
|
||||
|
||||
根据2.2可行性分析章节,可在应用的首页`IndexM`页面中监听整个页面的根组件:
|
||||
|
||||
```ts
|
||||
struct IndexM {
|
||||
build() {
|
||||
Flex(...) { ... }
|
||||
.id('HOME_ROOT')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在页面的`aboutToAppear()`的末尾使用`inspector`注册监听根组件`HOME_ROOT`的绘制完成:
|
||||
|
||||
```ts
|
||||
// 创建首页根组件观察器,用于监听 HOME_ROOT 的绘制事件
|
||||
private drawnListener = this.getUIContext().getUIInspector().createComponentObserver('HOME_ROOT');
|
||||
|
||||
// 页面绘制完成后通知系统,以便记录首帧渲染完成时间
|
||||
private onDrawComplete = () => {
|
||||
// draw 事件可能因页面刷新重复触发,上报前先取消监听
|
||||
this.drawnListener.off('draw', this.onDrawComplete);
|
||||
this.context.reportDrawnCompleted(() => {});
|
||||
};
|
||||
|
||||
aboutToAppear() {
|
||||
// ...
|
||||
// 页面即将显示时注册绘制监听;根组件完成绘制后触发回调
|
||||
this.drawnListener.on('draw', this.onDrawComplete);
|
||||
}
|
||||
|
||||
aboutToDisappear() {
|
||||
// 页面退出时兜底取消监听,避免观察器残留
|
||||
this.drawnListener.off('draw', this.onDrawComplete);
|
||||
}
|
||||
```
|
||||
|
||||
整体步骤:
|
||||
|
||||
1. 为页面根组件添加id `HOME_ROOT`;
|
||||
2. 首页组件获取系统绘制完成`inspector`监听对象,用于监听`HOME_ROOT`的绘制完成事件`draw`;
|
||||
3. `draw` 事件回调中调用`reportDrawnCompleted`函数,用于在`HiAppEvent`监听的`APP_LAUNCH`事件函数中获取渲染完毕的时间戳;
|
||||
4. 在项目已有的事件监听架构中,独立添加`APP_LAUNCH`事件的监听,并根据2.1可行性分析中获取到的`icon_input_time`启动时间和`extend_time`首页绘制完成时间来计算应用启动耗时;
|
||||
5. 复用已有的事件上传方法上传启动时间。
|
||||
|
||||
## 4. 时间校验
|
||||
|
||||
同一次冷启动中,应用日志与性能时间线的首帧耗时结果基本一致:日志记录为 `1270 ms`,性能时间线显示为约 `1.2 s`。说明 `APP_LAUNCH` 事件中的首屏绘制完成时间可用于启动耗时统计。
|
||||
|
||||
<img src="report-assets/08-app-launch-log-time-check.jpg" alt="APP_LAUNCH 日志首屏绘制完成时间" width="640">
|
||||
|
||||
**图8:首屏绘制完成耗时为 `1270 ms`。**
|
||||
|
||||
<img src="report-assets/09-profiler-first-frame-time-check.jpg" alt="性能时间线首帧耗时" width="640">
|
||||
|
||||
**图9:性能时间线中 `First Frame - App Phase` 显示首帧耗时约 `1.2 s`,与应用日志结果一致。**
|
||||
|
||||
另外补充首帧效果图:
|
||||
|
||||
<img src="report-assets/10-home-first-frame.png" alt="首页首帧效果图" width="360">
|
||||
|
||||
**图10:首页首帧。
|
||||
|
||||
|
||||
## 5. 启动性能劣化预防
|
||||
|
||||
冷启动自动化回归可分三个阶段执行:
|
||||
|
||||
- **开发自测阶段:** 首页初始化、SDK、路由或资源加载有改动时,开发者在本机或固定测试机进行少量冷启动采样,尽早发现明显劣化。
|
||||
- **合并主干阶段:** 每次合入主干后由 CI 在固定真机执行冷启动回归,作为日常预警并持续积累稳定基线。
|
||||
- **发布候选阶段:** 提测包、灰度包或正式发布包执行完整的多次采样和门禁判断;P95 明显劣化、启动失败或白屏时阻断发布。
|
||||
|
||||
发布候选阶段适合作为正式质量门禁;合并主干阶段是最有价值的预防点,因为问题仍容易定位和回滚。
|
||||
|
||||
### 5.1 开发自测:`work` 与 `feature` 分支对比
|
||||
|
||||
开发阶段可约定 `feature` 为稳定基线分支,`work` 为当前编码分支。通过脚本自动构建、安装并测试两个分支的冷启动耗时,输出首帧耗时及其差值;若 `work` 明显慢于 `feature`,开发者应在提交前检查新增初始化、资源加载或首页渲染逻辑。
|
||||
|
||||
为避免波动的影响,两个分支应在同一台真机、相同网络条件下交替执行多次(比如10次)冷启动,可使用平均数进行比较,并输出性能测试对比文档,该脚本用于开发阶段性能劣化预警。
|
||||
|
||||
### 5.2 feature合并主干或者主干发布前
|
||||
|
||||
合并主干后,CI 可在在固定真机上执行冷启动回归,持续对比当前版本与稳定基线的首帧耗时,作为日常预警。发现明显劣化时,开发者可问题进入发布流程前完成分析和修复。
|
||||
|
||||
正式发布前,应扩大采样次数,重点比较判断整体启动体验是否变慢,若明显劣化,或出现启动失败等问题,可暂停合并并保留测试日志和性能证据用于定位和修复。
|
||||
|
||||
|
||||
# APP_FREEZE
|
||||
|
||||
## 1. 现状
|
||||
|
||||
当前 `APP_FREEZE` 已具备捕获和上传能力。
|
||||
|
||||
|
||||
## 2. 数据分析
|
||||
版本号: FUHM37.08.390 / 1.20.02
|
||||
时间范围: 2026-07-24 ~ 2026-07-30
|
||||
|
||||
| 平台 | 数量 |
|
||||
| ------- | --- |
|
||||
| 华为 | 100 |
|
||||
| Grafnan | 97 |
|
||||
|
||||
## 3. 目标
|
||||
|
||||
按数量排名,优先定位和治理排名靠前的10个 `APP_FREEZE` 异常。
|
||||
|
||||
# 改进目标与方向
|
||||
|
||||
## 改进目标
|
||||
|
||||
| 优先级 | 改进项 | 具体措施 | 验收标准 |
|
||||
| --- | ------------ | ------------------------------------------ | --------------------------------- |
|
||||
| P0 | 崩溃采集 | 将上报崩溃事件中的OOM识别拆分出来,仅上报真正的崩溃事件。OOM单独通过接口上报。 | 崩溃指标应只包含实际的崩溃类型,OOM独立统计。可大幅减小崩溃率。 |
|
||||
| P1 | CPP崩溃数量与实际不符 | 分析找出CPP_CRASH漏报或是漏统计原因,并修复 | 展示崩溃数量的统计中,应包含全部的CPP_CRASH数据。 |
|
||||
| P0 | 启动耗时统计上报 | 监听应用启动事件获取用户点击桌面事件和应用首屏渲染结束时间,计算出启动耗时 | 可准确获取应用启动耗时并上报统计。 |
|
||||
| | | | |
|
||||
| | | | |
|
||||
| 优先级 | 事项 | 具体措施 | 现状 | 验收标准 | 预计时间 |
|
||||
| --- | --------------- | ----------------------------------------------------------- | ---------------------------------------------- | ------------------------- | ------------ |
|
||||
| P0 | 启动耗时 | 监听应用启动事件获取用户点击桌面事件和应用首屏渲染结束时间,计算出启动耗时。上传按照其他端后端上传方案。 | 期货鸿蒙缺失该功能 | 可准确获取应用启动耗时并上报统计。 | 4d(编码+自测+验收) |
|
||||
| P0 | app freeze(ANR) | 目前APP_FREEZE已有捕获上传,但基本未治理。可先选取量级排名前10的先治理 | FUHM37.08.390(1.20.02)版本冻屏幕率:<br>**7.7 (日/万)** | 预计占比修复后降低到:<br>**4(日/万)** | 4d(定位+修复) |
|
||||
| P0 | 崩溃 | 目前的崩溃事件中的存在大量OOM事件,占比超过一半。需对OOM单独治理。 | FUHM37.08.390(1.20.02)崩溃率:<br>**5.08 (日/万)** | 目前OOM较难定位,还需继续定位问题。 | 1d定位 |
|
||||
| P1 | 崩溃开发阶段预防措施 | 在 Debug阶段增加崩溃诊断弹窗,在崩溃应用退出后下一次启动弹出,帮助开发者和测试在开发测试阶段发现和定位崩溃信息。 | | | |
|
||||
| P1 | 启动性能劣化预防措施 | 编写自动化性能测试脚本,自动测试、获取和对比待合并和目标分支的启动性能。遇到性能劣化的情况可以及时修复。 | | | |
|
||||
| | | | | | |
|
||||
| | | | | | |
|
||||
|
||||
## 后续方向
|
||||
1. 分析项目http监控相关代码,定位并降低HTTP请求慢率等指标
|
||||
2. 分析项目web监控相关代码,
|
||||
|
||||
1. 资源泄漏:问华为
|
||||
2. 网络相关。定位并降低HTTP请求慢率等指标。定位并优化WEB白屏和慢加载等问题。
|
||||
3. 待完善......
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
Reference in New Issue
Block a user