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...