前言

现在网上大部分鸿蒙教程都是单页面 Demo,代码全部堆在 entry 里,没有分层、没有封装,稍微做大一点就出现循环依赖、内存泄漏、UI 样式混乱、主包体积过大等问题。 本文基于 API23 搭建一套企业级四层架构脚手架,整合 HAR 静态复用 + HSP 动态分包,封装网络、数据库、路由、弹窗、权限、文件、日志、全局状态、双主题全套底层工具,附带通用列表、页面状态组件完整源码,所有代码可直接复制运行,适合课程设计、毕业设计、商用项目初始化。

一、整体四层架构设计

依赖流向单向不可逆:页面 / HSP → 业务 HAR → har_base 底层库

  1. entry:应用入口、常驻页面、全局初始化、HSP 加载调度
  2. har_base:无业务底层工具、全局常量、通用 UI 组件(全项目共享)
  3. har_biz:业务静态 HAR,存放业务实体、数据库操作、业务卡片
  4. hsp_large:重型低频页面动态分包,按需加载,用完卸载优化内存与包体积

架构核心优势

  1. 解耦分层,新增业务只新增模块,不改动原有代码
  2. HSP 动态分包大幅缩小主 HAP 安装包,提升冷启动速度
  3. 全套底层工具一次封装全项目复用,无需重复写原生 API 模板
  4. 统一深浅双主题,无硬编码色值、尺寸,全局一键换肤
  5. 完善生命周期资源回收机制,杜绝弹窗残留、请求残留、内存泄漏

二、底层公共工具完整源码(har_base)

2.1 全局主题工具 theme.ets

ets

import AppStorage from '@ohos.arkui.state.AppStorage'

// 全局尺寸常量,全项目统一规范
export const SizeConstant = {
  gapXs: 4,
  gapSm: 8,
  gapMd: 12,
  gapLg: 16,
  radiusSm: 8,
  radiusMd: 12,
  radiusFull: 999,
  fontTip: 12,
  fontAux: 14,
  fontMain: 16,
  fontTitle: 22,
  btnNormal: 44
}

// 浅色主题配色
const LightTheme = {
  primary: "#007DFF",
  danger: "#F53F3F",
  success: "#00B42A",
  pageBg: "#F5F5F5",
  cardBg: "#FFFFFF",
  textPrimary: "#1D2129",
  textSecondary: "#86909C",
  divider: "#E5E6EB"
}

// 深色主题配色
const DarkTheme = {
  primary: "#36A3FF",
  danger: "#FF5C5C",
  success: "#37D05B",
  pageBg: "#121212",
  cardBg: "#1E1E1E",
  textPrimary: "#F2F3F5",
  textSecondary: "#86909C",
  divider: "#333333"
}

export function getThemeColor() {
  const isDark = AppStorage.Get<boolean>("darkMode") ?? false
  return isDark ? DarkTheme : LightTheme
}

export default {
  getColor: getThemeColor,
  getSize: SizeConstant
}

2.2 全局持久化状态管理 store.ets

ets

import AppStorage from '@ohos.arkui.state.AppStorage'
import PersistentStorage from '@ohos.arkui.state.PersistentStorage'
import common from '@ohos.app.ability.common'
import LogUtil from './log_util'

class GlobalStore {
  private ctx: common.UIAbilityContext | null = null

  setContext(context: common.UIAbilityContext) {
    this.ctx = context
  }

  // 应用启动一次性绑定持久化字段
  initPersistent() {
    PersistentStorage.link("darkMode", false)
    PersistentStorage.link("isLogin", false)
    PersistentStorage.link("token", "")
    LogUtil.info("GlobalStore", "持久化状态绑定完成")
  }

  set<T>(key: string, value: T) {
    AppStorage.SetOrCreate(key, value)
  }

  get<T>(key: string): T {
    return AppStorage.Get<T>(key)
  }

  // 登录批量赋值
  login(token: string) {
    this.set("isLogin", true)
    this.set("token", token)
  }

  // 退出登录清空登录状态
  logout() {
    this.set("isLogin", false)
    this.set("token", "")
  }

  // 切换深色模式
  toggleDark() {
    const val = this.get<boolean>("darkMode")
    this.set("darkMode", !val)
  }
}

export default new GlobalStore()

2.3 分级脱敏日志工具 log_util.ets

ets

import FileUtil from './file_util'
import common from '@ohos.app.ability.common'

export enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3
}

class LogUtil {
  private ctx: common.UIAbilityContext | null = null
  private level: LogLevel = LogLevel.DEBUG
  private logPath: string = ""
  private readonly MAX_FILE_SIZE = 2 * 1024 * 1024

  setContext(ctx: common.UIAbilityContext) {
    this.ctx = ctx
    this.logPath = `${FileUtil.getFilesRoot()}/app.log`
  }

  setLogLevel(lv: LogLevel) {
    this.level = lv
  }

  // 敏感信息脱敏
  private desensitize(msg: string): string {
    msg = msg.replace(/token[:=]\s*\w+/gi, "token=******")
    msg = msg.replace(/1[3-9]\d{9}/g, "1*********")
    return msg
  }

  private getTime(): string {
    return new Date().toLocaleString()
  }

  private async checkFileSize() {
    const size = await FileUtil.getDirSize(this.logPath)
    if (size >= this.MAX_FILE_SIZE) {
      await FileUtil.deleteFile(this.logPath)
    }
  }

  private async writeFile(tag: string, levelStr: string, content: string) {
    if (!this.ctx) return
    await this.checkFileSize()
    const oldText = await FileUtil.readText(this.logPath)
    const line = `${this.getTime()} ${levelStr} [${tag}] ${content}\n`
    await FileUtil.writeText(this.logPath, oldText + line)
  }

  private print(tag: string, lv: LogLevel, ...args: any[]) {
    if (lv < this.level) return
    let content = args.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(" ")
    content = this.desensitize(content)
    let levelStr = ""
    switch (lv) {
      case LogLevel.DEBUG:
        levelStr = "[DEBUG]"
        console.debug(levelStr, tag, content)
        break
      case LogLevel.INFO:
        levelStr = "[INFO]"
        console.info(levelStr, tag, content)
        break
      case LogLevel.WARN:
        levelStr = "[WARN]"
        console.warn(levelStr, tag, content)
        break
      case LogLevel.ERROR:
        levelStr = "[ERROR]"
        console.error(levelStr, tag, content)
        break
    }
    this.writeFile(tag, levelStr, content)
  }

  debug(tag: string, ...args: any[]) {
    this.print(tag, LogLevel.DEBUG, ...args)
  }
  info(tag: string, ...args: any[]) {
    this.print(tag, LogLevel.INFO, ...args)
  }
  warn(tag: string, ...args: any[]) {
    this.print(tag, LogLevel.WARN, ...args)
  }
  error(tag: string, ...args: any[]) {
    this.print(tag, LogLevel.ERROR, ...args)
  }
}

export default new LogUtil()

2.4 沙盒文件工具 file_util.ets

ets

import fs from '@ohos.file.fs'
import fileio from '@ohos.fileio'
import common from '@ohos.app.ability.common'

class FileUtil {
  private ctx: common.UIAbilityContext | null = null

  setContext(ctx: common.UIAbilityContext) {
    this.ctx = ctx
  }

  getFilesDir(): string {
    return this.ctx?.filesDir ?? ""
  }

  getCacheDir(): string {
    return this.ctx?.cacheDir ?? ""
  }

  // 递归创建文件夹
  async mkdir(path: string): Promise<boolean> {
    try {
      const exist = await fs.access(path)
      if (!exist) await fs.mkdir(path, true)
      return true
    } catch (e) {
      return false
    }
  }

  // 写入文本
  async writeText(path: string, text: string): Promise<boolean> {
    try {
      const parent = path.substring(0, path.lastIndexOf("/"))
      await this.mkdir(parent)
      const fd = await fileio.open(path, fileio.OpenMode.CREATE | fileio.OpenMode.WRITE_ONLY)
      await fileio.write(fd, text)
      await fileio.close(fd)
      return true
    } catch {
      return false
    }
  }

  // 读取文本
  async readText(path: string): Promise<string> {
    try {
      const exist = await fs.access(path)
      if (!exist) return ""
      const fd = await fileio.open(path, fileio.OpenMode.READ_ONLY)
      const stat = await fileio.fstat(fd)
      const buf = new ArrayBuffer(stat.size)
      await fileio.read(fd, buf)
      await fileio.close(fd)
      return String.fromCharCode(...new Uint8Array(buf))
    } catch {
      return ""
    }
  }

  // 删除文件
  async deleteFile(path: string): Promise<boolean> {
    try {
      const exist = await fs.access(path)
      if (!exist) return true
      await fs.unlink(path)
      return true
    } catch {
      return false
    }
  }

  // 递归删除文件夹
  async deleteDir(path: string): Promise<boolean> {
    try {
      const exist = await fs.access(path)
      if (!exist) return true
      await fs.rmdir(path, { recursive: true })
      return true
    } catch {
      return false
    }
  }

  // 计算文件夹总大小
  async getDirSize(path: string): Promise<number> {
    let total = 0
    try {
      const exist = await fs.access(path)
      if (!exist) return 0
      const dir = await fs.opendir(path)
      let entry
      while ((entry = await dir.read()) !== null) {
        const full = `${path}/${entry.name}`
        if (entry.isFile()) {
          const stat = await fs.stat(full)
          total += stat.size
        } else if (entry.isDirectory()) {
          total += await this.getDirSize(full)
        }
      }
      await dir.close()
    } catch {}
    return total
  }

  // 格式化字节单位
  formatSize(byte: number): string {
    if (byte < 1024) return `${byte} B`
    if (byte < 1024 * 1024) return `${(byte / 1024).toFixed(2)} KB`
    return `${(byte / (1024 * 1024)).toFixed(2)} MB`
  }

  // 清空缓存目录
  async clearCache() {
    const cache = this.getCacheDir()
    return await this.deleteDir(cache)
  }
}

export default new FileUtil()

三、全局通用 UI 组件源码

3.1 StateView 页面状态兜底组件

ets

import ThemeUtil from '../utils/theme'
import animateTo from '@ohos.arkui.animateTo'

export enum PageState {
  LOADING,
  EMPTY,
  ERROR,
  CONTENT
}

@Component
export struct StateView {
  @Param state: PageState = PageState.LOADING
  @Param onRetry: () => void = () => {}
  @BuilderParam content: () => void
  @State rotate: number = 0

  aboutToAppear() {
    animateTo({
      duration: 1200,
      iterations: Infinity,
      curve: Curve.Linear
    }, () => {
      this.rotate += 360
    })
  }

  aboutToDisappear() {
    this.rotate = 0
  }

  @Builder Loading() {
    const color = ThemeUtil.getColor()
    const size = ThemeUtil.getSize()
    Column({ space: size.gapMd }) {
      Text("⟳")
        .fontSize(42)
        .rotate({ angle: this.rotate })
      Text("数据加载中...")
        .fontSize(size.fontAux)
        .fontColor(color.textSecondary)
    }
    .width("100%")
    .height("100%")
    .justifyContent(FlexAlign.Center)
  }

  @Builder Empty() {
    const color = ThemeUtil.getColor()
    const size = ThemeUtil.getSize()
    Column({ space: size.gapMd }) {
      Image($r("sys.media.ohos_ic_public_empty"))
        .width(80)
        .fillColor(color.textSecondary)
      Text("暂无数据")
        .fontSize(size.fontAux)
        .fontColor(color.textSecondary)
    }
    .width("100%")
    .height("100%")
    .justifyContent(FlexAlign.Center)
  }

  @Builder Error() {
    const color = ThemeUtil.getColor()
    const size = ThemeUtil.getSize()
    Column({ space: size.gapLg }) {
      Image($r("sys.media.ohos_ic_public_fail"))
        .width(80)
        .fillColor(color.textSecondary)
      Text("加载失败,请点击重试")
        .fontSize(size.fontAux)
        .fontColor(color.textSecondary)
      Button("重试")
        .width(140)
        .height(size.btnNormal)
        .backgroundColor(color.primary)
        .borderRadius(size.radiusSm)
        .onClick(this.onRetry)
    }
    .width("100%")
    .height("100%")
    .justifyContent(FlexAlign.Center)
  }

  build() {
    Column().layoutWeight(1) {
      if (this.state === PageState.LOADING) {
        this.Loading()
      } else if (this.state === PageState.EMPTY) {
        this.Empty()
      } else if (this.state === PageState.ERROR) {
        this.Error()
      } else {
        this.content()
      }
    }
  }
}

3.2 RefreshListView 下拉刷新 + 上拉分页列表

ets

import ThemeUtil from '../utils/theme'

export interface PageInfo {
  pageIndex: number
  pageSize: number
  isNoMore: boolean
}

@Component
export struct RefreshListView<T> {
  @Param list: T[] = []
  @Param page: PageInfo
  @Param onRefresh: () => Promise<void>
  @Param onLoadMore: () => Promise<void>
  @BuilderParam itemBuilder: (item: T, index: number) => void

  @State pullOffset: number = 0
  @State isRefreshing: boolean = false
  @State isLoadMore: boolean = false
  private readonly TRIGGER_HEIGHT = 60
  private readonly MAX_PULL = 80

  @Builder PullHeader() {
    if (this.pullOffset > 0 || this.isRefreshing) {
      Row()
        .width("100%")
        .height(this.pullOffset)
        .justifyContent(FlexAlign.Center)
      {
        Text("⟳")
        Text(this.isRefreshing ? "刷新中..." : "下拉刷新")
      }
    }
  }

  @Builder Footer() {
    const color = ThemeUtil.getColor()
    const size = ThemeUtil.getSize()
    Row()
      .width("100%")
      .height(50)
      .justifyContent(FlexAlign.Center)
    {
      if (this.isLoadMore) {
        Text("加载更多...").fontColor(color.textSecondary).fontSize(size.fontAux)
      } else if (this.page.isNoMore) {
        Text("———— 没有更多数据 ————").fontColor(color.divider).fontSize(size.fontAux)
      } else {
        Text("上拉加载更多").fontColor(color.textSecondary).fontSize(size.fontAux)
      }
    }
  }

  // 拖拽移动
  handleDrag(deltaY: number) {
    if (this.isRefreshing) return
    let val = this.pullOffset + deltaY
    if (val < 0) val = 0
    if (val > this.MAX_PULL) val = this.MAX_PULL
    this.pullOffset = val
  }

  // 松手执行刷新
  async handleRelease() {
    if (this.isRefreshing) return
    if (this.pullOffset < this.TRIGGER_HEIGHT) {
      animateTo({ duration: 200 }, () => this.pullOffset = 0)
      return
    }
    this.isRefreshing = true
    try {
      await this.onRefresh()
    } finally {
      animateTo({ duration: 200 }, () => this.pullOffset = 0, () => {
        this.isRefreshing = false
      })
    }
  }

  // 滚动触底加载
  handleScroll(info: ListScrollInfo) {
    if (this.isRefreshing || this.isLoadMore || this.page.isNoMore) return
    const threshold = 150
    if (info.scrollOffset + info.scrollHeight >= info.totalHeight - threshold) {
      this.isLoadMore = true
      this.onLoadMore().finally(() => this.isLoadMore = false)
    }
  }

  build() {
    Column() {
      this.PullHeader()
      List({ space: ThemeUtil.getSize().gapMd }) {
        ForEach(this.list, (item: T, idx: number) => {
          ListItem() {
            this.itemBuilder(item, idx)
          }
        })
        ListFooter() {
          this.Footer()
        }
      }
      .layoutWeight(1)
      .edgeEffect(EdgeEffect.None)
      .onScroll((info) => this.handleScroll(info))
      .gesture(
        PanGesture({ direction: PanDirection.Vertical, distance: 5 })
          .onChange(e => {
            if (e.offsetY > 0) this.handleDrag(e.deltaY)
          })
          .onActionEnd(() => this.handleRelease())
      )
    }
    .width("100%")
  }
}

四、页面调用示例(设置页:深色模式 + 缓存清理)

ets

import GlobalStore from '@ohos:har_base/utils/store'
import FileUtil from '@ohos:har_base/utils/file_util'
import ThemeUtil from '@ohos:har_base/utils/theme'
import LogUtil from '@ohos:har_base/utils/log_util'

@Entry
@Component
struct SettingPage {
  @State darkMode: boolean = false
  @State cacheText: string = "计算中..."

  aboutToAppear() {
    this.darkMode = GlobalStore.get<boolean>("darkMode")
    this.calcCacheSize()
  }

  async calcCacheSize() {
    const byte = await FileUtil.getDirSize(FileUtil.getCacheDir())
    this.cacheText = FileUtil.formatSize(byte)
  }

  async clearCache() {
    await FileUtil.clearCache()
    LogUtil.info("SettingPage", "缓存清理完成")
    await this.calcCacheSize()
  }

  build() {
    const color = ThemeUtil.getColor()
    const size = ThemeUtil.getSize()
    Column({ space: size.gapLg })
      .width("100%")
      .height("100%")
      .padding(size.gapMd)
      .backgroundColor(color.pageBg)
    {
      Text("系统设置")
        .fontSize(size.fontTitle)
        .fontColor(color.textPrimary)
        .margin({ top: 40 })

      Row()
        .width("100%")
        .padding(size.gapLg)
        .borderRadius(size.radiusMd)
        .backgroundColor(color.cardBg)
      {
        Text("深色模式")
          .fontSize(size.fontMain)
          .fontColor(color.textPrimary)
          .layoutWeight(1)
        Toggle({ isOn: this.darkMode })
          .onChange((v) => {
            GlobalStore.toggleDark()
            this.darkMode = v
          })
      }

      Row()
        .width("100%")
        .padding(size.gapLg)
        .borderRadius(size.radiusMd)
        .backgroundColor(color.cardBg)
      {
        Text(`缓存占用:${this.cacheText}`)
          .fontSize(size.fontMain)
          .fontColor(color.textPrimary)
          .layoutWeight(1)
        Button("清理缓存")
          .height(size.btnNormal)
          .backgroundColor(color.danger)
          .borderRadius(size.radiusSm)
          .onClick(() => this.clearCache())
      }
    }
  }
}

五、项目踩坑总结(CSDN 干货总结)

  1. 循环依赖报错:业务 HAR 之间禁止互相导入,只能依赖 har_base,跨业务逻辑放 entry 中转
  2. 深色模式切换页面不变色:不要把 getThemeColor () 存入 @State 缓存,build 内实时调用
  3. HSP 内存持续上涨:页面 aboutToDisappear 必须调用分包卸载接口,释放图片 PixelMap 资源
  4. 弹窗遮罩残留卡死页面:页面销毁统一调用 DialogUtil 批量关闭全部弹窗
  5. 下拉列表重复请求:RefreshListView 内置 isLoadMore 锁,禁止自己额外加滚动监听
  6. 打包图片错乱:各模块资源添加专属前缀 common_/note_/user_,避免同名资源覆盖

六、文末总结

这套四层模块化脚手架覆盖鸿蒙应用开发全流程,底层工具、通用 UI 组件开箱即用,兼顾开发效率、代码规范、运行性能,适配 API23 纯血 OpenHarmony NEXT。 无论是课程作业、毕业设计,还是小型商用项目,都可以直接基于该架构二次开发,只需替换业务 HAR 与 HSP 页面,底层基础能力无需重复开发。 需要完整工程目录、模块依赖配置、EntryAbility 全局初始化代码可以评论留言! 觉得文章有用欢迎点赞收藏,持续更新 OpenHarmony 实战干货!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐