feat(entry): 建立 MVVM+data 分层骨架,接入 V2 状态管理与 Navigation 路由
This commit is contained in:
33
entry/src/main/ets/data/api/UserApi.ets
Normal file
33
entry/src/main/ets/data/api/UserApi.ets
Normal 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;
|
||||
}
|
||||
}
|
||||
35
entry/src/main/ets/data/repository/UserRepository.ets
Normal file
35
entry/src/main/ets/data/repository/UserRepository.ets
Normal 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;
|
||||
}
|
||||
}
|
||||
23
entry/src/main/ets/di/ServiceLocator.ets
Normal file
23
entry/src/main/ets/di/ServiceLocator.ets
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,62 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
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 {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} 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 {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
|
||||
Logger.info(TAG, '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));
|
||||
Logger.error(TAG, `Failed to load content: ${JSON.stringify(err)}`);
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||
Logger.info(TAG, 'Succeeded in loading the content.');
|
||||
});
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
// Main window is destroyed, release UI related resources
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
|
||||
Logger.info(TAG, 'onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
// Ability has brought to foreground
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
Logger.info(TAG, 'onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
// Ability has back to background
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
Logger.info(TAG, 'onBackground');
|
||||
}
|
||||
}
|
||||
9
entry/src/main/ets/model/User.ets
Normal file
9
entry/src/main/ets/model/User.ets
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 领域模型示例:@ObservedV2 + @Trace 实现字段级可观察。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class User {
|
||||
@Trace id: string = '';
|
||||
@Trace name: string = '';
|
||||
@Trace email: string = '';
|
||||
}
|
||||
35
entry/src/main/ets/pages/DetailPage.ets
Normal file
35
entry/src/main/ets/pages/DetailPage.ets
Normal 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('详情')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@Component
|
||||
@ComponentV2
|
||||
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() {
|
||||
RelativeContainer() {
|
||||
Text(this.message)
|
||||
.id('HelloWorld')
|
||||
.fontSize($r('app.float.page_text_font_size'))
|
||||
Navigation(this.pathStack) {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
Text(this.title)
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.alignRules({
|
||||
center: { anchor: '__container__', align: VerticalAlign.Center },
|
||||
middle: { anchor: '__container__', align: HorizontalAlign.Center }
|
||||
})
|
||||
.onClick(() => {
|
||||
this.message = 'Welcome';
|
||||
.fontColor(AppColor.textPrimary)
|
||||
|
||||
if (this.viewModel.loading) {
|
||||
LoadingView({ text: '加载中...' })
|
||||
} else if (this.viewModel.error.length > 0) {
|
||||
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, '来自首页的参数');
|
||||
}
|
||||
})
|
||||
}
|
||||
.height('100%')
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(AppSpacing.lg)
|
||||
.backgroundColor(AppColor.background)
|
||||
}
|
||||
.navDestination(this.routeMap)
|
||||
.mode(NavigationMode.Stack)
|
||||
.hideTitleBar(true)
|
||||
}
|
||||
|
||||
@Builder
|
||||
routeMap(name: string, param: Object) {
|
||||
RouteMap(name, param)
|
||||
}
|
||||
}
|
||||
30
entry/src/main/ets/router/AppRouter.ets
Normal file
30
entry/src/main/ets/router/AppRouter.ets
Normal 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();
|
||||
}
|
||||
}
|
||||
18
entry/src/main/ets/router/RouteMap.ets
Normal file
18
entry/src/main/ets/router/RouteMap.ets
Normal 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 })
|
||||
}
|
||||
}
|
||||
25
entry/src/main/ets/view/StatusCard.ets
Normal file
25
entry/src/main/ets/view/StatusCard.ets
Normal 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)
|
||||
}
|
||||
}
|
||||
41
entry/src/main/ets/viewmodel/HomeViewModel.ets
Normal file
41
entry/src/main/ets/viewmodel/HomeViewModel.ets
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user