feat(entry): 建立 MVVM+data 分层骨架,接入 V2 状态管理与 Navigation 路由

This commit is contained in:
AndyChen1024
2026-09-11 16:22:45 +08:00
parent 3a116df871
commit 058b978f25
11 changed files with 336 additions and 33 deletions

View File

@@ -0,0 +1,33 @@
import { HttpClient, ApiResult, ApiError } from 'common';
/**
* 后端用户接口 DTO(与后端字段约定对齐)。
*/
export class UserDto {
id: string = '';
name: string = '';
email: string = '';
}
/**
* 用户相关网络请求服务:负责发请求、解析统一响应、返回 DTO。
* TODO: BASE_URL 应改为环境注入(debug/release 分离)。
*/
export class UserApi {
private static readonly BASE_URL: string = 'https://api.example.com';
private readonly http: HttpClient;
constructor(http: HttpClient) {
this.http = http;
}
async fetchUser(id: string): Promise<UserDto> {
const raw = await this.http.get(`${UserApi.BASE_URL}/api/user/${id}`);
const result = JSON.parse(raw) as ApiResult<UserDto>;
if (!result.isSuccess()) {
throw ApiError.business(result.code, result.message);
}
return result.data;
}
}

View File

@@ -0,0 +1,35 @@
import { User } from '../../model/User';
import { UserApi, UserDto } from '../api/UserApi';
/**
* 用户数据仓库契约(抽象类):ViewModel 只依赖此契约,便于替换 mock/真实实现。
* 注:用抽象类而非 interface,是为了在 ServiceLocator 中做 Object -> 契约 的强转时兼容 ArkTS。
*/
export abstract class UserRepository {
abstract getUser(id: string): Promise<User>;
}
/**
* 真实实现:调用 UserApi,做 DTO -> 领域模型映射。
*/
export class UserRepositoryImpl extends UserRepository {
private readonly api: UserApi;
constructor(api: UserApi) {
super();
this.api = api;
}
async getUser(id: string): Promise<User> {
const dto: UserDto = await this.api.fetchUser(id);
return UserRepositoryImpl.toDomain(dto);
}
private static toDomain(dto: UserDto): User {
const user = new User();
user.id = dto.id;
user.name = dto.name;
user.email = dto.email;
return user;
}
}

View File

@@ -0,0 +1,23 @@
/**
* 轻量服务定位器:ArkTS 无反射,用字符串 key + 显式类型转换做手动 DI。
* 约定:启动时在 EntryAbility.onCreate 中注册单例,业务侧通过 get() 解析后强转。
*/
export class ServiceLocator {
private static readonly services: Map<string, Object> = new Map<string, Object>();
static register(key: string, service: Object): void {
ServiceLocator.services.set(key, service);
}
static get(key: string): Object {
const service = ServiceLocator.services.get(key);
if (service === undefined) {
throw new Error(`[ServiceLocator] 未注册的服务: ${key}`);
}
return service;
}
static clear(): void {
ServiceLocator.services.clear();
}
}

View File

@@ -1,48 +1,62 @@
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI'; import { window } from '@kit.ArkUI';
import { HttpClient, AuthInterceptor, SessionManager, Logger } from 'common';
import { ServiceLocator } from '../di/ServiceLocator';
import { UserApi } from '../data/api/UserApi';
import { UserRepositoryImpl } from '../data/repository/UserRepository';
const DOMAIN = 0x0000; const TAG: string = 'EntryAbility';
export default class EntryAbility extends UIAbility { export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try { try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) { } catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); Logger.error(TAG, `Failed to set colorMode: ${JSON.stringify(err)}`);
} }
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); Logger.info(TAG, 'onCreate');
// 装配依赖:会话 -> 网络 -> 数据层
this.initDependencies();
}
private initDependencies(): void {
// 会话(持久化)
SessionManager.init();
// 网络层
const httpClient = new HttpClient();
httpClient.addInterceptor(new AuthInterceptor());
ServiceLocator.register('httpClient', httpClient);
// 数据层
const userApi = new UserApi(httpClient);
ServiceLocator.register('userRepository', new UserRepositoryImpl(userApi));
} }
onDestroy(): void { onDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); Logger.info(TAG, 'onDestroy');
} }
onWindowStageCreate(windowStage: window.WindowStage): void { 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) => { windowStage.loadContent('pages/Index', (err) => {
if (err.code) { if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); Logger.error(TAG, `Failed to load content: ${JSON.stringify(err)}`);
return; return;
} }
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); Logger.info(TAG, 'Succeeded in loading the content.');
}); });
} }
onWindowStageDestroy(): void { onWindowStageDestroy(): void {
// Main window is destroyed, release UI related resources Logger.info(TAG, 'onWindowStageDestroy');
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
} }
onForeground(): void { onForeground(): void {
// Ability has brought to foreground Logger.info(TAG, 'onForeground');
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
} }
onBackground(): void { onBackground(): void {
// Ability has back to background Logger.info(TAG, 'onBackground');
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
} }
} }

View File

@@ -0,0 +1,9 @@
/**
* 领域模型示例:@ObservedV2 + @Trace 实现字段级可观察。
*/
@ObservedV2
export class User {
@Trace id: string = '';
@Trace name: string = '';
@Trace email: string = '';
}

View File

@@ -0,0 +1,35 @@
import { AppColor, AppSpacing, AppFontSize, AppButton } from 'uikit';
import { AppRouter } from '../router/AppRouter';
/**
* 详情页:NavDestination 页面,演示 Navigation 路由与 @Param 入参。
*/
@ComponentV2
export struct DetailPage {
@Param title: string = '';
build() {
NavDestination() {
Column({ space: AppSpacing.md }) {
Text(this.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(AppColor.textPrimary)
Text('这是一个 NavDestination 页面,用于演示 Navigation 路由。')
.fontSize(AppFontSize.body)
.fontColor(AppColor.textSecondary)
AppButton({
text: '返回',
onTap: () => {
AppRouter.pop();
}
})
}
.width('100%')
.height('100%')
.padding(AppSpacing.lg)
.backgroundColor(AppColor.background)
}
.title('详情')
}
}

View File

@@ -1,23 +1,63 @@
import { AppColor, AppSpacing, AppButton, LoadingView, EmptyView } from 'uikit';
import { HomeViewModel } from '../viewmodel/HomeViewModel';
import { User } from '../model/User';
import { AppRouter } from '../router/AppRouter';
import { RouteMap, RouteNames } from '../router/RouteMap';
import { StatusCard } from '../view/StatusCard';
/**
* 首页(@Entry):Navigation 根容器,仅编排。
* 演示「页面 -> ViewModel -> Repository -> Api -> HttpClient」完整链路。
*/
@Entry @Entry
@Component @ComponentV2
struct Index { struct Index {
@State message: string = 'Hello World'; @Local pathStack: NavPathStack = new NavPathStack();
@Local viewModel: HomeViewModel = HomeViewModel.create();
@Local title: string = 'Harmony Client';
aboutToAppear(): void {
AppRouter.init(this.pathStack);
this.viewModel.load();
}
build() { build() {
RelativeContainer() { Navigation(this.pathStack) {
Text(this.message) Column({ space: AppSpacing.md }) {
.id('HelloWorld') Text(this.title)
.fontSize($r('app.float.page_text_font_size')) .fontSize(24)
.fontWeight(FontWeight.Bold) .fontWeight(FontWeight.Bold)
.alignRules({ .fontColor(AppColor.textPrimary)
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center } if (this.viewModel.loading) {
}) LoadingView({ text: '加载中...' })
.onClick(() => { } else if (this.viewModel.error.length > 0) {
this.message = 'Welcome'; EmptyView({ text: this.viewModel.error })
} else {
ForEach(this.viewModel.users, (user: User) => {
StatusCard({ title: user.name, subtitle: user.email })
}, (user: User) => user.id)
}
AppButton({
text: '进入详情页',
onTap: () => {
AppRouter.push(RouteNames.DETAIL, '来自首页的参数');
}
}) })
}
.width('100%')
.height('100%')
.padding(AppSpacing.lg)
.backgroundColor(AppColor.background)
} }
.height('100%') .navDestination(this.routeMap)
.width('100%') .mode(NavigationMode.Stack)
.hideTitleBar(true)
}
@Builder
routeMap(name: string, param: Object) {
RouteMap(name, param)
} }
} }

View File

@@ -0,0 +1,30 @@
/**
* 应用级导航封装:持有全局 NavPathStack,页面通过静态方法导航。
*/
export class AppRouter {
private static stack: NavPathStack | null = null;
static init(stack: NavPathStack): void {
AppRouter.stack = stack;
}
static push(name: string, param?: Object): void {
AppRouter.stack?.pushPathByName(name, param);
}
static replace(name: string, param?: Object): void {
AppRouter.stack?.replacePathByName(name, param);
}
static pop(): void {
AppRouter.stack?.pop();
}
static popToName(name: string): void {
AppRouter.stack?.popToName(name);
}
static clear(): void {
AppRouter.stack?.clear();
}
}

View File

@@ -0,0 +1,18 @@
import { DetailPage } from '../pages/DetailPage';
/**
* 路由名常量。
*/
export class RouteNames {
static readonly DETAIL: string = 'detail';
}
/**
* 路由表:name -> 页面。由 Navigation.navDestination 调用分发。
*/
@Builder
export function RouteMap(name: string, param: Object) {
if (name === RouteNames.DETAIL) {
DetailPage({ title: param as string })
}
}

View File

@@ -0,0 +1,25 @@
import { AppColor, AppSpacing } from 'uikit';
/**
* 可复用信息卡片(示例):@ComponentV2 + @Param 输入。
*/
@ComponentV2
export struct StatusCard {
@Param title: string = '';
@Param subtitle: string = '';
build() {
Column({ space: AppSpacing.sm }) {
Text(this.title)
.fontSize(16)
.fontColor(AppColor.textPrimary)
Text(this.subtitle)
.fontSize(14)
.fontColor(AppColor.textSecondary)
}
.width('100%')
.padding(AppSpacing.md)
.backgroundColor(AppColor.white)
.borderRadius(8)
}
}

View File

@@ -0,0 +1,41 @@
import { User } from '../model/User';
import { UserRepository } from '../data/repository/UserRepository';
import { ServiceLocator } from '../di/ServiceLocator';
/**
* 首页视图模型:@ObservedV2 三态(loading / error / data)。
* 只依赖 UserRepository 契约,不直接接触网络层。
*/
@ObservedV2
export class HomeViewModel {
@Trace users: User[] = [];
@Trace loading: boolean = false;
@Trace error: string = '';
private readonly repository: UserRepository;
constructor(repository: UserRepository) {
this.repository = repository;
}
/**
* 工厂:从 DI 容器解析依赖并装配,避免页面直接触碰 data 层类型。
*/
static create(): HomeViewModel {
return new HomeViewModel(ServiceLocator.get('userRepository') as UserRepository);
}
async load(): Promise<void> {
this.loading = true;
this.error = '';
try {
// 演示:拉取一个示例用户;真实业务可替换为列表接口。
const user = await this.repository.getUser('demo-user');
this.users = [user];
} catch (err) {
this.error = JSON.stringify(err);
} finally {
this.loading = false;
}
}
}