鸿蒙Harmony ArkUI实战项目:仿网易云音乐NCMusicHarmony

NCMusicHarmony

前言

2024鸿蒙元年,写个鸿蒙的项目练练手~

写什么项目?之前学习的时候写过Jetpack Compose的仿网易云应用NCMusic、 Compose Desktop的仿网易云桌面应用NCMusicDesktop、Flutter的仿段子乐应用joke_fun_flutter, 这次选择了网易云,写个鸿蒙版的仿网易云NCMusicHarmony。

普通开发者不配像企业开发者有api11的开发权限,只能基于api9来开发。不写不知道,一写吓一跳,基于api9已有的组件想要来实现一些常见的交互效果并不太好搞。莎士比亚说放不放弃这是一个问题~最后还是决定强撸一把直至灰飞烟没吧。

撸出来的效果还算差强任意,but~无图言吊?上动图先

状态管理

先简单概括一下ArkUI中的状态管理

  • @State 用该修饰符修饰的变量表明该变量具有状态
  • @Prop 父子组件之间的状态传递,单向驱动,api9只支持基础数据类型
  • @Link 父子组件之间的数状态传递,双向驱动
  • @Observed、@ObjectLink 父子组件之间的状态传递,处理具有嵌套类型变量的场景
  • @Provide、@Consume 多层级后代组件的数据传递,双向驱动
  • @Watch 状态变量变化监听
  • LocalStorage 内存级别,不会写入硬盘,UIAbility内状态共享
  • AppStorage 内存级别,不会写入硬盘,应用内状态共享
  • PersistentStorage 状态持久化,配合AppStorage实现应用内写入磁盘

项目结构

TabLayout、TabPager

官方虽然提供了Tabs、TabContent组件,在api9中,能够实现类似android中TabLayout+ViewPager的联动切换效果,但是实际开发中,有一些效果却不好实现, 例如Tab切换时候Indicator的偏移动画,而且构建UI的时候,标签栏和标签对应的内容是写在一起的,对于标签栏可以随着屏幕滚动并且吸顶的效果,不好实现。 所以自己定义了TabLayout和TabPager,二者配合TabLayoutPagerMediator实现联动。使用伪代码如下:

less 复制代码
  @State tabediator: TabLayoutPagerMediator = new TabLayoutPagerMediator({
    tabItems: [],  // Tab数据源
    cacheCount: 1,  // 页面缓存数量
    indexChangedCallback: (index: number) => {
      // tab索引变化回调
    }
  })
  TabLayout({mediator: this.tabMediator})
  TabPager({ mediator: this.tabMediator,
       TabPageBuilder: (index: number) => {
         // 页面插槽
       }})

最开始的版本TabPager并不支持懒加载,在做音乐播放界面的时候,有一个唱片左右滑动切歌的效果,是使用TabPager实现的,当歌曲列表有700多首歌时, 滑动切换起来很卡。临时替换成官方的Swiper来实现,虽然指定了Swiper的cacheCount,还是卡卡的,模拟了10000条数据,直接卡死。 不知道Swiper的cacheCount是怎么实现的。后面把自定义的TabLayout也改造成支持懒加载, 像Swiper一样通过指定cacheCount来缓存页面,模拟了10000条数据操作起来效果还行。

CollapsibleLayout

嵌套滑动在日常开发中随处可见,但是在api9中,却没有简单的api提供给开发者快速实现 (在b站看到一个视频,在api10中,官方已经新增相关的nested api来处理这类场景,可惜我还不配使用api10),所以自定义了CollapsibleLayout来处理这种场景。

先看一张图\

  • AppBar:固定在页面顶部的标题栏
  • ScrollHeader:可滚动头部
  • StickyHeader:粘性头部,随着页面滚动后吸附在AppBar下方
  • Content:内容区域
    CollapsibleLayout实现的大概思路在ScrollHeader、StickyHeader、Content外层套一个OuterScroller,Content内部如果有多个滚动区域(例如Content是个TabPager), 每个可滚动区域都提供一个InnerScroller,通过Scroller的onScrollFrameBegin方法来处理滑动逻辑。对外提供CollapsibleMediator来实现嵌套滑动。

RefreshLayout

自定义了RefreshLayout来实现下拉刷新、上拉加载功能,使用伪代码如下:

typescript 复制代码
  refreshMediator: RefreshMediator = new RefreshMediator()
  RefreshLayout({ refreshMediator: this.refreshMediator,
      ContentBuilder: () => { 
        // 内容区域
        this.ContentBuilder()
      },
      onRefresh: () => {
          // 刷新逻辑
          this.refreshMediator.finishRefresh(true)
      },
      onLoadMore: () => {
        // 加载更多逻辑
        this.refreshMediator.finishLoadMore()
      }
    })
   @Builder ContentBuilder() {
      List() {
      }.onReachEnd(() => {
          this.refreshMediator.scrollerReachEnd()
      })
      .onAreaChange((_, newValue: Area) => {
          this.refreshMediator.scrollerAreaChange(newValue)
      })
      .onScrollFrameBegin((offset: number, _) => {
         this.mediator.refreshMediator.getScrollerFrameRemainOffset(offset)
      })
  }

使用起来还挺复杂,还需调用refreshMediator的scrollerReachEnd()、scrollerAreaChange()、getScrollerFrameRemainOffset()这三坨方法~ 其实最开始的实现是在RefreshLayout内部嵌了一个Scroll组件来协调滑动,这样在外层调用就不用写那三坨方法了。但是有一个问题:列表嵌套在Scroller里面, 必须指定列表的高度,不指定高度的话就算列表用了LazyForEach,ArkUI也是一次性全部加载所有item的,这样数据源一多就会卡卡卡,如果指了列表的高度, 那么内嵌的Scroll组件的onReachEnd()又不会回调,无法实现上拉加载更多的功能。所以后面把内嵌的Scroll组件去掉,由外层调用来通知refreshMediator。

另外还有一点,RefreshLayout只能在List上工作,对于网格列表Grid,瀑布流WaterFlow是不起作用的。因为在api9中,Grid和WaterFlow没有提供onReachEnd()和onScrollFrameBegin() 的api,只有List组件才有,离离原上谱~本来List、Grid、WaterFlow是同一系列的组件,提供的api却有点割裂~不过在万能b站上看到,api10上面onReachEnd()这些api在Grid、WaterFlow组件上应该是有了, 后面再看看吧。

动画

官方文档中可以看到属性动画、显式动画、页面间转场、路径动画。就不细说了。

属性动画、显式动画用起来很简单,不过却找不到暂停动画、停止动画、获取当前动画进度的api。整的我很难受。 后面发现要暂停动画、停止动画、获取当前动画进度,应该调用AnimatorResult、AnimatorOptions这类api,需要用到请自行查看相关使用方法。

另外,对于页面间转场动画,一直找不到如何统一设置整个应用的页面转场动画,不在各个页面重写pageTransition的话,默认都是左右slide的动画~ 有大佬知道麻烦告知弟弟。

网络请求

项目中的网络请求,直接用了官方的http,没有引入第三方框架,只是做了简单的封装。

一般页面涉及到网络请求,都会有页面态、下拉刷新结果状态、上拉加载更多结果状态的切换,ArkUI中的组件又没有继承的概念,各个组件都要单独处理的话也是很难受。 最后项目中定义了ViewStateLayout、ViewStatePagingLayout、请求时构建RequestOptions时传入对于组件的ViewState、PagingLayoutMediator来统一处理。

ViewStateLayout会自动切换页面加载态、正常态、错误态、空白态,ViewStatePagingLayout除了页面态、还会自动切换刷新头部、加载更多尾部的状态

  • RequestOptions的定义
typescript 复制代码
export interface IRequestOptions {
  // 请求url
  url: string
  // 请求参数
  data?: object
  // 和页面绑定的ViewState
  viewState?: ViewState,
  // 分页协调工具
  pagingMediator?: PagingLayoutMediator,
  // 请求成功条件,默认code==200
  successCondition?: (result: object) => boolean
  // 判断空条件
  emptyCondition?: (result: object) => boolean
  // 分页数据转换
  pagingListConverter?: (result: object) => object[]
  // 请求失败时是否还返回result
  interceptWhenNoSuccess?: boolean
}
  • ViewStateLayout使用伪代码
less 复制代码
 @State: result: Result
 ViewStateLayout({ onLoadData: async (viewState) => {
      // 网络请求
      this.result = await viewModel.fetchData(viewState)
    } }) {
      // 正常态布局
    }
    
 class ViewModel extends BaseViewModel {
   async fetchData(viewState: ViewState) : Promise<Result> {
     await this.get<Result>(
       new RequestOptions({
          url: "",
          data: "",
          viewState: viewState
      }))
   }
 }
  • ViewStatePagingLayout使用伪代码
less 复制代码
  @State pagingLayoutMediator: PagingLayoutMediator = new PagingLayoutMediator({})
  ViewStatePagingLayout({
      mediator: $pagingLayoutMediator,
      ItemBuilder: (item: object, _) => {
        this.ItemBuilder(item)
      },
      onLoadData: async (viewState: ViewState) => {
        viewModel.fetchData((viewState, this.pagingLayoutMediator)
      },
   })
  class ViewModel extends BaseViewModel {
    async fetchData(viewState: ViewState, pagingMediator: PagingLayoutMediator) : Promise<Result> {
     this.get<Result>(
      new RequestOptions({
        url: "",
        data: "",
        viewState: viewState,
        pagingMediator: pagingMediator,
        pagingListConverter: (result: Result) => {
          // 将result转换成list数据源
        }
      }))
   }
 }

音乐播放

音乐播放功能,用的是官方的AVPlayer。项目中封装了NCPlayer负责实际的播放功能,MusicPlayController来调用NCPlayer和驱动各个音乐播放相关组件的渲染。 关于如何驱动各个播放相关组件UI渲染的问题,最开始实现是想利用AppStorage,往AppStorage中更新一些播放的关键信息来驱动UI渲染, 后面发现在音乐播放界面,AppStorage更新时,唱片旋转动画会卡顿。索性都改写成利用emitter来通信,实现播放相关组件UI渲染。

主题切换

主题切换,大概思路是仿照Compose的那套主题切换,首先定义一个基础的取色盘IThemePalette

yaml 复制代码
export interface IThemePalette {
  primary: ResourceColor
  secondary: ResourceColor,
  pure: ResourceColor,
  divider: ResourceColor,
  commonBackground: ResourceColor,
  deepenBackground: ResourceColor,
  titleBackground: ResourceColor,
  navBarBackground: ResourceColor,
  drawerBackground: ResourceColor,
  firstText: ResourceColor,
  secondText: ResourceColor,
  thirdText: ResourceColor,
  firstIcon: ResourceColor,
  secondIcon: ResourceColor,
  thirdIcon: ResourceColor,
}

然后各个主题的取色盘都实现IThemePalette,例如默认主题取色盘DefaultThemePalette

ini 复制代码
export class DefaultThemePalette implements IThemePalette {
  primary: ResourceColor = "#FFF0484E"
  secondary: ResourceColor = "#FFF0888C"
  pure: ResourceColor = "#FFFFFFFF"
  divider: ResourceColor = "#FFDDDDDD"
  commonBackground: ResourceColor = "#FFFFFFFF"
  deepenBackground: ResourceColor = "#FFEEEEEE"
  titleBackground: ResourceColor = "#FFFAFAFA"
  navBarBackground: ResourceColor = "#FFFAFAFA"
  drawerBackground: ResourceColor = "#FFFAFAFA"
  firstText: ResourceColor = "#FF333333"
  secondText: ResourceColor = "#FF666666"
  thirdText: ResourceColor = "#FF999999"
  firstIcon: ResourceColor = "#FF333333"
  secondIcon: ResourceColor = "#FF666666"
  thirdIcon: ResourceColor = "#FF999999"
}

然后定义AppTheme共外部使用

arduino 复制代码
// 默认主题
export const defaultThemePalette = new DefaultThemePalette()
// 黑色主题
export const darkThemePalette = new DarkThemePalette()
// 橙色主题
export const originThemePalette = new OriginThemePalette()
// 绿色主题
export const greenThemePalette = new GreenThemePalette()

export class AppTheme {
  /**
   * 获取主题取色盘
   */
  static palette(themeType: ThemeType): IThemePalette {
    if (themeType == ThemeType.DEFAULT) {
      return defaultThemePalette
    } else if (themeType == ThemeType.DARK) {
      return darkThemePalette
    } else if (themeType == ThemeType.ORIGIN) {
      return originThemePalette
    } else if (themeType == ThemeType.GREEN) {
      return greenThemePalette
    } else {
      return defaultThemePalette
    }
  }
}

// 主题类型
export const THEME_TYPE = "THEME_TYPE"

至于如何使用,需要用到AppStorage,在组件内先获取当前主题色类型,然后调用AppTheme的palette()方法,获取到对应主题的取色盘的颜色, 当AppStorage中的主题类型发送变化时,组件就会自动切换颜色。

less 复制代码
  @StorageLink(THEME_TYPE) themeType: ThemeType = ThemeType.DEFAULT
  Text(item.name).fontColor(AppTheme.palette(this.themeType).firstText)

最后

累了,不想写了。新手鸿蒙开发,代码不规范请不吝指教,如若有帮助请给个star 源码地址:github.com/sskEvan/NCM...

相关推荐
脾气有点小暴5 分钟前
uniapp通用递进式步骤组件
前端·javascript·vue.js·uni-app·uniapp
问道飞鱼6 分钟前
【前端知识】从前端请求到后端返回:Gzip压缩全链路配置指南
前端·状态模式·gzip·请求头
小杨累了12 分钟前
CSS Keyframes 实现 Vue 无缝无限轮播
前端
小扎仙森13 分钟前
html引导页
前端·html
AllBlue15 分钟前
unity调用安卓方法
android·unity·游戏引擎
PWRJOY24 分钟前
Android Studio中安卓模拟器打不开,报错The emulator process for AVD has terminated
android·ide·android studio
小飞侠在吗36 分钟前
vue toRefs 与 toRef
前端·javascript·vue.js
csuzhucong39 分钟前
斜转魔方、斜转扭曲魔方
前端·c++·算法
燃烧的土豆1 小时前
100¥ 实现的React项目 Keep-Alive 缓存控件
前端·react.js·ai编程
半生过往1 小时前
前端运行PHP 快速上手 使用 PHPStudy Pro 详细搭建与使用指南
开发语言·前端·php