From 058b978f25d2a3dedc1639509795e345b4d8d4d7 Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 16:22:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(entry):=20=E5=BB=BA=E7=AB=8B=20MVVM+data?= =?UTF-8?q?=20=E5=88=86=E5=B1=82=E9=AA=A8=E6=9E=B6=EF=BC=8C=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20V2=20=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=20Navigation=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entry/src/main/ets/data/api/UserApi.ets | 33 +++++++++ .../ets/data/repository/UserRepository.ets | 35 +++++++++ entry/src/main/ets/di/ServiceLocator.ets | 23 ++++++ .../main/ets/entryability/EntryAbility.ets | 48 ++++++++----- entry/src/main/ets/model/User.ets | 9 +++ entry/src/main/ets/pages/DetailPage.ets | 35 +++++++++ entry/src/main/ets/pages/Index.ets | 72 ++++++++++++++----- entry/src/main/ets/router/AppRouter.ets | 30 ++++++++ entry/src/main/ets/router/RouteMap.ets | 18 +++++ entry/src/main/ets/view/StatusCard.ets | 25 +++++++ .../src/main/ets/viewmodel/HomeViewModel.ets | 41 +++++++++++ 11 files changed, 336 insertions(+), 33 deletions(-) create mode 100644 entry/src/main/ets/data/api/UserApi.ets create mode 100644 entry/src/main/ets/data/repository/UserRepository.ets create mode 100644 entry/src/main/ets/di/ServiceLocator.ets create mode 100644 entry/src/main/ets/model/User.ets create mode 100644 entry/src/main/ets/pages/DetailPage.ets create mode 100644 entry/src/main/ets/router/AppRouter.ets create mode 100644 entry/src/main/ets/router/RouteMap.ets create mode 100644 entry/src/main/ets/view/StatusCard.ets create mode 100644 entry/src/main/ets/viewmodel/HomeViewModel.ets diff --git a/entry/src/main/ets/data/api/UserApi.ets b/entry/src/main/ets/data/api/UserApi.ets new file mode 100644 index 0000000..e421c17 --- /dev/null +++ b/entry/src/main/ets/data/api/UserApi.ets @@ -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 { + const raw = await this.http.get(`${UserApi.BASE_URL}/api/user/${id}`); + const result = JSON.parse(raw) as ApiResult; + if (!result.isSuccess()) { + throw ApiError.business(result.code, result.message); + } + return result.data; + } +} diff --git a/entry/src/main/ets/data/repository/UserRepository.ets b/entry/src/main/ets/data/repository/UserRepository.ets new file mode 100644 index 0000000..2a24e26 --- /dev/null +++ b/entry/src/main/ets/data/repository/UserRepository.ets @@ -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; +} + +/** + * 真实实现:调用 UserApi,做 DTO -> 领域模型映射。 + */ +export class UserRepositoryImpl extends UserRepository { + private readonly api: UserApi; + + constructor(api: UserApi) { + super(); + this.api = api; + } + + async getUser(id: string): Promise { + 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; + } +} diff --git a/entry/src/main/ets/di/ServiceLocator.ets b/entry/src/main/ets/di/ServiceLocator.ets new file mode 100644 index 0000000..d80de31 --- /dev/null +++ b/entry/src/main/ets/di/ServiceLocator.ets @@ -0,0 +1,23 @@ +/** + * 轻量服务定位器:ArkTS 无反射,用字符串 key + 显式类型转换做手动 DI。 + * 约定:启动时在 EntryAbility.onCreate 中注册单例,业务侧通过 get() 解析后强转。 + */ +export class ServiceLocator { + private static readonly services: Map = new Map(); + + 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(); + } +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 091797f..3aa22e7 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -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'); } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/model/User.ets b/entry/src/main/ets/model/User.ets new file mode 100644 index 0000000..a5e6e5d --- /dev/null +++ b/entry/src/main/ets/model/User.ets @@ -0,0 +1,9 @@ +/** + * 领域模型示例:@ObservedV2 + @Trace 实现字段级可观察。 + */ +@ObservedV2 +export class User { + @Trace id: string = ''; + @Trace name: string = ''; + @Trace email: string = ''; +} diff --git a/entry/src/main/ets/pages/DetailPage.ets b/entry/src/main/ets/pages/DetailPage.ets new file mode 100644 index 0000000..95c3514 --- /dev/null +++ b/entry/src/main/ets/pages/DetailPage.ets @@ -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('详情') + } +} diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 8e2d24a..8e56a14 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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')) - .fontWeight(FontWeight.Bold) - .alignRules({ - center: { anchor: '__container__', align: VerticalAlign.Center }, - middle: { anchor: '__container__', align: HorizontalAlign.Center } - }) - .onClick(() => { - this.message = 'Welcome'; + Navigation(this.pathStack) { + Column({ space: AppSpacing.md }) { + Text(this.title) + .fontSize(24) + .fontWeight(FontWeight.Bold) + .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, '来自首页的参数'); + } }) + } + .width('100%') + .height('100%') + .padding(AppSpacing.lg) + .backgroundColor(AppColor.background) } - .height('100%') - .width('100%') + .navDestination(this.routeMap) + .mode(NavigationMode.Stack) + .hideTitleBar(true) + } + + @Builder + routeMap(name: string, param: Object) { + RouteMap(name, param) } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/router/AppRouter.ets b/entry/src/main/ets/router/AppRouter.ets new file mode 100644 index 0000000..ac41dd0 --- /dev/null +++ b/entry/src/main/ets/router/AppRouter.ets @@ -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(); + } +} diff --git a/entry/src/main/ets/router/RouteMap.ets b/entry/src/main/ets/router/RouteMap.ets new file mode 100644 index 0000000..8cca25b --- /dev/null +++ b/entry/src/main/ets/router/RouteMap.ets @@ -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 }) + } +} diff --git a/entry/src/main/ets/view/StatusCard.ets b/entry/src/main/ets/view/StatusCard.ets new file mode 100644 index 0000000..49d9900 --- /dev/null +++ b/entry/src/main/ets/view/StatusCard.ets @@ -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) + } +} diff --git a/entry/src/main/ets/viewmodel/HomeViewModel.ets b/entry/src/main/ets/viewmodel/HomeViewModel.ets new file mode 100644 index 0000000..864a134 --- /dev/null +++ b/entry/src/main/ets/viewmodel/HomeViewModel.ets @@ -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 { + 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; + } + } +}