HarmonyOS 開發中的錯誤處理策略:網路異常統一處理
穩健的網路層,從優雅的錯誤處理開始
你有沒有遇過這種情況:
使用者點擊「提交訂單」,頁面卡住了,沒有任何提示。使用者疑惑地點了第二次、第三次...最後發現是網路斷了,但訂單已經被提交了三次。
或者這樣:
// 頁面 A
try {
await api.submitOrder();
} catch (e) {
alert('網路錯誤');
}
// 頁面 B
try {
await api.getUser();
} catch (e) {
alert('請求失敗');
}
// 頁面 C
try {
await api.getProduct();
} catch (e) {
alert('出錯了');
}
三種不同的錯誤提示,使用者一頭霧水:到底是哪裡錯了?怎麼解決?
統一錯誤處理要解決的就是這個問題:
錯誤處理不是簡單的 try-catch,而是一個完整的處理鏈:
// network/error/types.ets
/**
* 錯誤碼列舉
* 定義所有可能的錯誤碼,方便統一管理
*/
export enum ErrorCode {
// 網路相關錯誤 (-1 ~ -99)
NETWORK_ERROR = -1, // 網路不可用
NETWORK_TIMEOUT = -2, // 請求逾時
NETWORK_DISCONNECT = -3, // 網路斷開
NETWORK_SLOW = -4, // 網路慢
// HTTP 協定錯誤 (100 ~ 599)
HTTP_BAD_REQUEST = 400, // 請求參數錯誤
HTTP_UNAUTHORIZED = 401, // 未授權
HTTP_FORBIDDEN = 403, // 禁止存取
HTTP_NOT_FOUND = 404, // 資源不存在
HTTP_METHOD_NOT_ALLOWED = 405, // 方法不允許
HTTP_REQUEST_TIMEOUT = 408, // 請求逾時
HTTP_CONFLICT = 409, // 衝突
HTTP_TOO_MANY_REQUESTS = 429, // 請求過多
HTTP_INTERNAL_ERROR = 500, // 伺服器內部錯誤
HTTP_BAD_GATEWAY = 502, // 閘道錯誤
HTTP_SERVICE_UNAVAILABLE = 503, // 服務不可用
HTTP_GATEWAY_TIMEOUT = 504, // 閘道逾時
// 業務錯誤 (1000 ~ 1999)
BIZ_SUCCESS = 0, // 成功
BIZ_PARAM_ERROR = 1001, // 參數錯誤
BIZ_DATA_NOT_FOUND = 1002, // 資料不存在
BIZ_PERMISSION_DENIED = 1003, // 權限不足
BIZ_TOKEN_EXPIRED = 1004, // Token 過期
BIZ_TOKEN_INVALID = 1005, // Token 無效
BIZ_USER_BANNED = 1006, // 使用者被封禁
BIZ_OPERATION_FAILED = 1007, // 操作失敗
// 用戶端錯誤 (2000 ~ 2999)
CLIENT_PARSE_ERROR = 2001, // 資料解析錯誤
CLIENT_CACHE_ERROR = 2002, // 快取錯誤
CLIENT_STORAGE_ERROR = 2003, // 儲存錯誤
// 未知錯誤
UNKNOWN = -9999
}
/**
* 錯誤嚴重程度
*/
export enum ErrorSeverity {
LOW = 'low', // 低:不影響使用,靜默處理
MEDIUM = 'medium', // 中:影響目前操作,提示使用者
HIGH = 'high', // 高:影響整體使用,彈窗提示
CRITICAL = 'critical' // 嚴重:應用無法使用,阻斷操作
}
/**
* 錯誤分類
*/
export enum ErrorCategory {
NETWORK = 'network', // 網路錯誤
HTTP = 'http', // HTTP 錯誤
BUSINESS = 'business', // 業務錯誤
CLIENT = 'client', // 用戶端錯誤
UNKNOWN = 'unknown' // 未知錯誤
}
/**
* API 錯誤基類
* 所有網路錯誤的基類,包含豐富的錯誤資訊
*/
export class ApiError extends Error {
/** 錯誤碼 */
readonly code: ErrorCode;
/** HTTP 狀態碼 */
readonly status: number;
/** 錯誤分類 */
readonly category: ErrorCategory;
/** 嚴重程度 */
readonly severity: ErrorSeverity;
/** 原始錯誤資料 */
readonly rawData?: any;
/** 是否可重試 */
readonly retryable: boolean;
/** 使用者友善的錯誤提示 */
readonly userMessage: string;
/** 錯誤發生時間 */
readonly timestamp: number;
constructor(config: {
message: string;
code: ErrorCode;
status?: number;
category?: ErrorCategory;
severity?: ErrorSeverity;
rawData?: any;
retryable?: boolean;
userMessage?: string;
}) {
super(config.message);
this.name = 'ApiError';
this.code = config.code;
this.status = config.status ?? 0;
this.category = config.category ?? ErrorCategory.UNKNOWN;
this.severity = config.severity ?? ErrorSeverity.MEDIUM;
this.rawData = config.rawData;
this.retryable = config.retryable ?? false;
this.userMessage = config.userMessage ?? config.message;
this.timestamp = Date.now();
}
/**
* 轉換為 JSON(用於日誌記錄)
*/
toJSON(): Record<string, any> {
return {
name: this.name,
message: this.message,
code: this.code,
status: this.status,
category: this.category,
severity: this.severity,
retryable: this.retryable,
userMessage: this.userMessage,
timestamp: this.timestamp,
stack: this.stack
};
}
}
/**
* 網路錯誤
*/
export class NetworkError extends ApiError {
constructor(message: string = '網路連線失敗', code: ErrorCode = ErrorCode.NETWORK_ERROR) {
super({
message,
code,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.HIGH,
retryable: true,
userMessage: '網路不太穩定,請檢查網路設定'
});
this.name = 'NetworkError';
}
}
/**
* 逾時錯誤
*/
export class TimeoutError extends ApiError {
constructor(message: string = '請求逾時') {
super({
message,
code: ErrorCode.NETWORK_TIMEOUT,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.MEDIUM,
retryable: true,
userMessage: '請求逾時,請稍後再試'
});
this.name = 'TimeoutError';
}
}
/**
* HTTP 錯誤
*/
export class HttpError extends ApiError {
constructor(status: number, message: string, data?: any) {
const severity = status >= 500 ? ErrorSeverity.HIGH : ErrorSeverity.MEDIUM;
const retryable = [408, 429, 500, 502, 503, 504].includes(status);
super({
message,
code: status as ErrorCode,
status,
category: ErrorCategory.HTTP,
severity,
rawData: data,
retryable,
userMessage: HttpError.getUserMessage(status)
});
this.name = 'HttpError';
}
/**
* 根據狀態碼取得使用者提示
* @private
*/
private static getUserMessage(status: number): string {
const messages: Record<number, string> = {
400: '請求參數錯誤',
401: '請先登入',
403: '沒有存取權限',
404: '請求的資源不存在',
408: '請求逾時',
429: '請求過於頻繁,請稍後再試',
500: '伺服器忙碌中,請稍後再試',
502: '閘道錯誤',
503: '服務暫時不可用',
504: '閘道逾時'
};
return messages[status] || `請求失敗(${status})`;
}
}
/**
* 業務錯誤
*/
export class BusinessError extends ApiError {
constructor(code: number, message: string, data?: any) {
super({
message,
code: code as ErrorCode,
category: ErrorCategory.BUSINESS,
severity: ErrorSeverity.MEDIUM,
rawData: data,
retryable: false,
userMessage: message
});
this.name = 'BusinessError';
}
}
// network/error/ErrorHandler.ets
import {
ApiError, NetworkError, TimeoutError, HttpError, BusinessError,
ErrorCode, ErrorCategory, ErrorSeverity
} from './types';
import { promptAction } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
/**
* 錯誤處理器設定
*/
export interface ErrorHandlerConfig {
/** 是否顯示錯誤提示 */
showToast?: boolean;
/** 是否記錄日誌 */
logError?: boolean;
/** 是否上報錯誤 */
reportError?: boolean;
/** 自訂錯誤提示對應 */
customMessages?: Map<ErrorCode, string>;
/** 錯誤回呼 */
onError?: (error: ApiError) => void;
}
/**
* 錯誤處理器
* 統一處理所有網路錯誤
*/
export class ErrorHandler {
private config: ErrorHandlerConfig;
constructor(config: ErrorHandlerConfig = {}) {
this.config = {
showToast: true,
logError: true,
reportError: true,
...config
};
}
/**
* 處理錯誤
* @param error 原始錯誤
* @returns 轉換後的 ApiError
*/
handle(error: any): ApiError {
// 1. 轉換為 ApiError
const apiError = this.transform(error);
// 2. 記錄日誌
if (this.config.logError) {
this.logError(apiError);
}
// 3. 上報錯誤
if (this.config.reportError) {
this.reportError(apiError);
}
// 4. 顯示使用者提示
if (this.config.showToast && apiError.severity !== ErrorSeverity.LOW) {
this.showUserMessage(apiError);
}
// 5. 執行自訂回呼
if (this.config.onError) {
this.config.onError(apiError);
}
return apiError;
}
/**
* 轉換錯誤為 ApiError
* @private
*/
private transform(error: any): ApiError {
// 已經是 ApiError,直接返回
if (error instanceof ApiError) {
return error;
}
// 處理原生網路錯誤
if (this.isNetworkError(error)) {
return new NetworkError();
}
// 處理逾時錯誤
if (this.isTimeoutError(error)) {
return new TimeoutError();
}
// 處理 HTTP 錯誤回應
if (this.isHttpError(error)) {
return new HttpError(
error.responseCode || error.status,
error.message || 'HTTP Error',
error.result || error.data
);
}
// 處理業務錯誤
if (this.isBusinessError(error)) {
return new BusinessError(
error.code,
error.message,
error.data
);
}
// 未知錯誤
return new ApiError({
message: error.message || '未知錯誤',
code: ErrorCode.UNKNOWN,
category: ErrorCategory.UNKNOWN,
severity: ErrorSeverity.MEDIUM,
rawData: error
});
}
/**
* 判斷是否為網路錯誤
* @private
*/
private isNetworkError(error: any): boolean {
// HarmonyOS 網路錯誤碼
const networkErrorCodes = [2300001, 2300002, 2300003];
return networkErrorCodes.includes(error.code) ||
error.message?.includes('Network') ||
error.message?.includes('網路');
}
/**
* 判斷是否為逾時錯誤
* @private
*/
private isTimeoutError(error: any): boolean {
return error.code === 2300002 ||
error.message?.includes('Timeout') ||
error.message?.includes('逾時');
}
/**
* 判斷是否為 HTTP 錯誤
* @private
*/
private isHttpError(error: any): boolean {
return error.responseCode >= 400 || error.status >= 400;
}
/**
* 判斷是否為業務錯誤
* @private
*/
private isBusinessError(error: any): boolean {
return error.code !== undefined &&
error.code !== 0 &&
error.message !== undefined;
}
/**
* 記錄錯誤日誌
* @private
*/
private logError(error: ApiError): void {
const logContent = `
┌────────────────────────────────────────
│ [錯誤日誌] ${new Date(error.timestamp).toISOString()}
├────────────────────────────────────────
│ 類型: ${error.name}
│ 分類: ${error.category}
│ 嚴重程度: ${error.severity}
│ 錯誤碼: ${error.code}
│ HTTP 狀態: ${error.status}
│ 訊息: ${error.message}
│ 使用者提示: ${error.userMessage}
│ 可重試: ${error.retryable}
│ 堆疊: ${error.stack?.split('\n').slice(0, 3).join('\n')}
└────────────────────────────────────────
`;
// 根據嚴重程度選擇日誌等級
if (error.severity === ErrorSeverity.CRITICAL) {
hilog.error(0x0000, 'NetworkError', logContent);
} else if (error.severity === ErrorSeverity.HIGH) {
hilog.warn(0x0000, 'NetworkError', logContent);
} else {
hilog.info(0x0000, 'NetworkError', logContent);
}
}
/**
* 上報錯誤到伺服器
* @private
*/
private reportError(error: ApiError): void {
// 這裡可以整合 Sentry、Bugly 等錯誤監控平台
// 簡化範例:只記錄到本地
console.info('[ErrorHandler] 錯誤已上報:', error.code);
// 實際專案中可以:
// 1. 收集裝置資訊、使用者資訊
// 2. 打包錯誤資料
// 3. 傳送到錯誤監控平台
}
/**
* 顯示使用者提示
* @private
*/
private showUserMessage(error: ApiError): void {
// 使用自訂訊息或預設訊息
let message = this.config.customMessages?.get(error.code) || error.userMessage;
// 根據嚴重程度選擇提示方式
if (error.severity === ErrorSeverity.CRITICAL) {
// 嚴重錯誤:彈窗提示
promptAction.showDialog({
title: '錯誤',
message: message,
buttons: [{ text: '確定', color: '#E74C3C' }]
});
} else {
// 一般錯誤:Toast 提示
promptAction.showToast({
message: message,
duration: 3000
});
}
}
}
// network/error/ErrorInterceptor.ets
import { Interceptor, Response, RequestConfig } from '../types';
import { ErrorHandler, ApiError } from './ErrorHandler';
/**
* 錯誤處理攔截器
* 攔截所有請求錯誤,統一處理
*/
export class ErrorInterceptor implements Interceptor {
private errorHandler: ErrorHandler;
constructor(errorHandler: ErrorHandler) {
this.errorHandler = errorHandler;
}
/**
* 請求攔截:無操作
*/
async beforeRequest(config: RequestConfig): Promise<RequestConfig> {
return config;
}
/**
* 回應攔截:檢查錯誤
*/
async afterResponse<T>(response: Response<T>): Promise<Response<T>> {
// 檢查 HTTP 狀態碼
if (response.status >= 200 && response.status < 300) {
// 請求成功,檢查業務狀態碼
const data = response.data as any;
if (data && typeof data === 'object' && 'code' in data) {
// 後端統一回傳格式:{ code, message, data }
if (data.code !== 0) {
// 業務錯誤
throw this.errorHandler.handle({
code: data.code,
message: data.message || data.msg || '業務錯誤',
data: data.data
});
}
// 業務成功,返回真實資料
return {
...response,
data: data.data as T
};
}
// 直接返回資料
return response;
}
// HTTP 錯誤,拋出
throw this.errorHandler.handle({
responseCode: response.status,
message: 'HTTP Error',
result: response.data
});
}
}
// components/ErrorBoundary.ets
import { ApiError, ErrorCategory } from '../network/error/types';
import { promptAction } from '@kit.ArkUI';
/**
* 錯誤邊界元件
* 用於包裹可能出錯的 UI 元件,提供降級顯示
*/
@Component
export struct ErrorBoundary {
@Prop hasError: boolean = false;
@Prop error: ApiError | null = null;
@BuilderParam defaultContent: () => void;
@BuilderParam errorContent?: () => void;
build() {
if (this.hasError && this.error) {
// 顯示錯誤 UI
if (this.errorContent) {
this.errorContent();
} else {
this.defaultErrorUI();
}
} else {
// 顯示正常內容
this.defaultContent();
}
}
/**
* 預設錯誤 UI
*/
@Builder
defaultErrorUI() {
Column() {
// 錯誤圖示
Image($r('app.media.ic_error'))
.width(80)
.height(80)
.margin({ bottom: 16 })
// 錯誤提示
Text(this.error?.userMessage || '出錯了')
.fontSize(16)
.fontColor('#666666')
.margin({ bottom: 24 })
// 重試按鈕(如果可重試)
if (this.error?.retryable) {
Button('重試')
.type(ButtonType.Capsule)
.backgroundColor('#4A90E2')
.fontColor(Color.White)
.onClick(() => {
// 通知父元件重試
this.hasError = false;
})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F5F5')
}
/**
* 捕獲錯誤
*/
catch(error: ApiError) {
this.error = error;
this.hasError = true;
}
/**
* 重設錯誤狀態
*/
reset() {
this.error = null;
this.hasError = false;
}
}
// pages/UserPage.ets
import { UserAPI } from '../api/UserAPI';
import { ApiError, NetworkError } from '../network/error/types';
import { ErrorHandler } from '../network/error/ErrorHandler';
@Entry
@Component
struct UserPage {
@State user: User | null = null;
@State loading: boolean = false;
@State error: ApiError | null = null;
private userApi: UserAPI = new UserAPI();
private errorHandler: ErrorHandler = new ErrorHandler({
showToast: false, // 頁面自己控制提示
onError: (err) => {
this.error = err;
}
});
aboutToAppear() {
this.loadUser();
}
/**
* 載入使用者資訊
*/
async loadUser() {
this.loading = true;
this.error = null;
try {
const response = await this.userApi.getCurrentUser();
this.user = response.data;
} catch (e) {
// 統一錯誤處理
const apiError = this.errorHandler.handle(e);
this.error = apiError;
} finally {
this.loading = false;
}
}
build() {
Column() {
if (this.loading) {
// 載入中
this.loadingUI();
} else if (this.error) {
// 錯誤狀態
this.errorUI();
} else if (this.user) {
// 正常顯示
this.contentUI();
}
}
.width('100%')
.height('100%')
}
/**
* 載入 UI
*/
@Builder
loadingUI() {
Column() {
LoadingProgress()
.width(48)
.height(48)
.color('#4A90E2')
Text('載入中...')
.fontSize(14)
.fontColor('#999999')
.margin({ top: 16 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
/**
* 錯誤 UI
*/
@Builder
errorUI() {
Column() {
// 根據錯誤類型顯示不同圖示
if (this.error?.category === 'network') {
Image($r('app.media.ic_network_error'))
.width(100)
.height(100)
} else {
Image($r('app.media.ic_error'))
.width(100)
.height(100)
}
Text(this.error?.userMessage || '出錯了')
.fontSize(16)
.fontColor('#666666')
.margin({ top: 16, bottom: 24 })
// 重試按鈕
if (this.error?.retryable) {
Button('重新載入')
.type(ButtonType.Capsule)
.backgroundColor('#4A90E2')
.fontColor(Color.White)
.width(120)
.onClick(() => {
this.loadUser();
})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F5F5')
}
/**
* 內容 UI
*/
@Builder
contentUI() {
Column() {
// 頭像
Image(this.user?.avatar)
.width(80)
.height(80)
.borderRadius(40)
.margin({ bottom: 16 })
// 使用者名稱
Text(this.user?.name || '')
.fontSize(20)
.fontWeight(FontWeight.Bold)
// 電子郵件
Text(this.user?.email || '')
.fontSize(14)
.fontColor('#999999')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
問題:直接把後端錯誤訊息顯示給使用者,可能洩漏敏感資訊。
解決:使用使用者友善的提示:
// ❌ 錯誤:直接顯示後端訊息
alert(error.message); // "SQL syntax error near 'select'"
// ✅ 正確:使用使用者友善提示
alert(error.userMessage); // "伺服器忙碌中,請稍後再試"
問題:catch 區塊中沒有處理錯誤,導致錯誤被「吞掉」。
// ❌ 錯誤:空 catch
try {
await api.getData();
} catch (e) {
// 什麼都不做,錯誤被吞掉
}
// ✅ 正確:至少記錄日誌
try {
await api.getData();
} catch (e) {
console.error('請求失敗:', e);
throw e; // 繼續拋出,讓上層處理
}
問題:Promise 的錯誤沒有被 try-catch 捕獲。
// ❌ 錯誤:async 函式外的錯誤不會被捕獲
try {
api.getData().then(data => {
// 這裡拋錯不會被外層 catch 捕獲
throw new Error('處理失敗');
});
} catch (e) {
// 捕獲不到
}
// ✅ 正確:使用 async/await
try {
const data = await api.getData();
// 處理資料
} catch (e) {
// 可以捕獲
}
問題:錯誤上報介面失敗,又觸發錯誤處理,導致循環。
解決:錯誤上報介面跳過錯誤處理:
// 錯誤上報使用獨立的 http 實例,不走攔截器
const reportHttp = http.createHttp();
// 不添加 ErrorInterceptor
// HarmonyOS 6 支援更豐富的日誌功能
import { hilog } from '@kit.PerformanceAnalysisKit';
// 支援格式化參數
hilog.error(0x0000, 'MyTag', 'Error occurred: %{public}s, code: %{public}d',
error.message, error.code);
// 支援不同日誌級別
hilog.debug(0x0000, 'MyTag', 'Debug message');
hilog.info(0x0000, 'MyTag', 'Info message');
hilog.warn(0x0000, 'MyTag', 'Warning message');
hilog.error(0x0000, 'MyTag', 'Error message');
hilog.fatal(0x0000, 'MyTag', 'Fatal message');
// HarmonyOS 6 Toast 支援更多設定
promptAction.showToast({
message: '操作成功',
duration: 2000,
bottom: 100, // 距離底部距離
showMode: promptAction.ToastShowMode.TOP_MOVED
});
// Dialog 支援更多按鈕樣式
promptAction.showDialog({
title: '確認刪除',
message: '刪除後無法復原',
buttons: [
{ text: '取消', color: '#666666' },
{ text: '刪除', color: '#E74C3C' }
]
});
// HarmonyOS 6 定義了更詳細的錯誤碼
// 網路錯誤碼範圍:2300001 - 2300999
// 2300001: 網路不可用
// 2300002: 連線逾時
// 2300003: 協定錯誤
// 2300004: URL 格式錯誤
// 2300005: DNS 解析失敗
// ...
// 可以根據系統錯誤碼對應
if (error.code >= 2300001 && error.code <= 2300999) {
// 網路相關錯誤
}
統一錯誤處理是網路層的「安全網」,讓應用在異常情況下也能優雅應對:
錯誤類型分類嚴重程度可重試使用者提示NetworkErrornetworkHIGH✅網路不太穩定TimeoutErrornetworkMEDIUM✅請求逾時HttpError(401)httpHIGH❌請先登入HttpError(404)httpMEDIUM❌資源不存在HttpError(500)httpHIGH✅伺服器忙碌中BusinessErrorbusinessMEDIUM❌後端訊息記住幾個原則:
下一篇我們深入請求取消與並發,看看 AbortController 的妙用。
💡 最佳實踐提示:建議在專案中建立錯誤碼文件,統一管理所有錯誤碼和對應的處理策略,方便團隊協作。