学习一项新技能,最好也是最快的方法就是动手实战。学习鸿蒙也一样,给自己定一个小目标,直接找项目练,这样进步是最快的。最近,我在网上看到360周董的一句话:“想干什么就去干,干得烂总比不干强!”这对我来说,就像一盏明灯,照亮了我心中的迷雾。于是,我决定将自己心中的想法付诸行动,开发一款既无广告又免费的鸿蒙观影App——“爱影家”。
开发背景
一直以来,手机上的观影体验总是被各种广告打扰,让人不胜其烦。我想,如果能有一个干净、免费的观影平台,对于像我一样的普通用户来说,那该有多好!于是,“爱影家”这个项目就应运而生了。即便因某些原因你懂的,肯定无法上架,但是自己装自己手机上用着美,且达到了依靠兴趣来快速上手的目的,足够啦。
从想法到行动
兴趣是最好的老师,学习鸿蒙开发也同样如此。给自己定一个小目标,去上手实战,这比什么都重要。我给自己定了一个目标——开发一款基于HarmonyOS NEXT的影视客户端APP。
项目概述
“爱影家”是一个基于HarmonyOS NEXT的开源影视客户端APP项目,主要分为三个页面:影视首页、知乎日报页和个人中心页。通过这个项目,我不仅学习了如何使用HarmonyOS NEXT进行应用开发,还了解了如何进行API数据交互、前端展示和后端处理等基本功能。
开源仓库地址1:https://gitee.com/yyz116/hmmovie
开源仓库地址2: https://atomgit.com/csdn-qq8864/hmmovie
好的作品是需要不断打磨,在你的学习和体验过程中有任何问题,欢迎到我的开源项目代码仓下面提交issue,持续优化。
开发步骤
-
搭建开发环境:首先,我确保安装了HarmonyOS NEXT的开发环境。根据官方文档进行安装和配置,一切准备就绪。
-
创建项目:使用DevEco Studio创建一个新的HarmonyOS NEXT项目,并选择ArkTS作为开发语言。一切从零开始,充满了未知和挑战。
-
配置网络请求:项目中引入了@nutpi/axios库,配置了网络请求的基础URL和拦截器。
import axios from '@nutpi/axios'; axios.defaults.baseURL = 'https://api.example.com'; axios.interceptors.request.use(config => { // 添加请求拦截器 return config; }, error => { return Promise.reject(error); });
-
实现影视首页功能:在影视首页中,我实现了轮播图、热映电影、即将上映电影和热门电视剧集的功能。通过API获取数据并在前端展示,整个过程充满了学习和实践的乐趣。以下是网络后台接口封装。在HarmonyOS NEXT开发环境中,可以使用@nutpi/axios库来简化网络请求的操作。本项目使用HarmonyOS NEXT框架和@nutpi/axios库实现一行代码写接口。大幅简化了网络接口的实现。
为什么选择@nutpi/axios?
nutpi/axios是坚果派对axios封装过的鸿蒙HTTP客户端库,用于简化axios库的使用和以最简单的形式写代码。使用nutpi/axios库可以大大简化代码,使网络接口变得简单直观。import {axiosClient, HttpPromise} from '../../utils/axiosClient'; import { HotMovieReq, MovieRespData, SwiperData } from '../bean/ApiTypes'; // 1.获取轮播图接口 export const getSwiperData = (): HttpPromise<SwiperData> => axiosClient.get({url:'/swiperdata'}); // 2.获取即将上映影视接口 export const getSoonMovie = (start:number, count:number): HttpPromise<MovieRespData> => axiosClient.post({url:'/soonmovie', data: { start:start, count:count }}); // 3.获取热门影视接口 export const getHotMovie = (req:HotMovieReq): HttpPromise<MovieRespData> => axiosClient.post({url:'/hotmovie', data:req}); // 4.获取最新上演影视接口 export const getNewMovie = (start:number, count:number): HttpPromise<MovieRespData> => axiosClient.post({url:'/newmovie', data: { start:start, count:count }}); // 5.获取最热门剧集接口 export const getHotTv = (start:number, count:number): HttpPromise<MovieRespData> => axiosClient.post({url:'/tvhot', data: { start:start, count:count }});
首页电影海报轮播图懒加载
// 轮播图
Swiper(this.swiperController) {
LazyForEach(this.swiperData, (item: SwiperItem) => {
Stack({ alignContent: Alignment.Center }) {
Image(item.imageUrl)
.width('100%')
.height(180)
.zIndex(1)
.onClick(() => {
this.pageStack.pushDestinationByName("MovieDetailPage", { id:item.id }).catch((e:Error)=>{
// 跳转失败,会返回错误码及错误信息
console.log(`catch exception: ${JSON.stringify(e)}`)
}).then(()=>{
// 跳转成功
});
})
// 显示轮播图标题
Text(item.title)
.padding(5)
.margin({ top: 135 })
.width('100%')
.height(60)
.textAlign(TextAlign.Center)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Clip })
.fontSize(22)
.fontColor(Color.White)
.opacity(100)// 设置标题的透明度 不透明度设为100%,表示完全不透明
.backgroundColor('#808080AA')// 背景颜色设为透明
.zIndex(2)
.onClick(() => {
this.pageStack.pushDestinationByName("MovieDetailPage", { id:item.id }).catch((e:Error)=>{
// 跳转失败,会返回错误码及错误信息
console.log(`catch exception: ${JSON.stringify(e)}`)
}).then(()=>{
// 跳转成功
});
})
}
}, (item: SwiperItem) => item.id)
}
.cachedCount(2)
.index(1)
.autoPlay(true)
.interval(4000)
.loop(true)
.indicatorInteractive(true)
.duration(1000)
.itemSpace(0)
.curve(Curve.Linear)
.onChange((index: number) => {
console.info(index.toString())
})
.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
console.info("index: " + index)
console.info("current offset: " + extraInfo.currentOffset)
})
.height(180) // 设置高度
电影详情页的设计
在电影详情页中,将使用 Badge、SymbolSpan、Button、Rating 等组件来展示电影的详细信息。
import { getDetailMv, getMovieSrc } from "../../common/api/movie"
import { Log } from "../../utils/logutil"
import { BusinessError } from "@kit.BasicServicesKit"
import { DetailMvResp, DetailMvRespCast } from "../../common/bean/DetailMvResp"
import { LengthMetrics, promptAction } from "@kit.ArkUI"
import { MvSourceResp } from "../../common/bean/MvSourceResp"
@Builder
export function MovieDetailPageBuilder() {
Detail()
}
@Component
struct Detail {
pageStack: NavPathStack = new NavPathStack()
private uid = ''
@State detailData: DetailMvResp | null = null;
private srcData: MvSourceResp | null = null;
private description: string = ''
private isToggle = false
@State toggleText: string = ''
@State toggleBtn: string = '展开'
build() {
NavDestination() {
Column({ space: 0 }) {
Row() {
Image(this.detailData?.images).objectFit(ImageFit.Auto).width(120).borderRadius(5)
Column({ space: 8 }) {
Text(this.detailData?.title).fontSize(18)
Text(this.detailData?.year + " " + this.detailData?.genre).fontSize(14)
Row() {
Badge({
count: this.detailData?.wish_count,
maxCount: 10000,
position: BadgePosition.RightTop,
style: { badgeSize: 22, badgeColor: '#fffab52a' }
}) {
Row() {
Text() {
SymbolSpan($r('sys.symbol.heart'))
.fontWeight(FontWeight.Lighter)
.fontSize(32)
.fontColor(['#fffab52a'])
}
Text('想看')
}.backgroundColor('#f8f4f5').borderRadius(5).padding(5)
}.padding(8)
Blank(10).width(40)
Badge({
count: this.detailData?.reviews_count,
maxCount: 10000,
position: BadgePosition.RightTop,
style: { badgeSize: 22, badgeColor: '#fffab52a' }
}) {
Row() {
Text() {
SymbolSpan($r('sys.symbol.star'))
.fontWeight(FontWeight.Lighter)
.fontSize(32)
.fontColor(['#fffab52a'])
}
Text('看过')
}.backgroundColor('#f8f4f5').borderRadius(5).padding(5)
}.padding(8)
}
Button('播放', { buttonStyle: ButtonStyleMode.NORMAL, role: ButtonRole.NORMAL })
.borderRadius(8)
.borderColor('#fffab52a')
.fontColor('#fffab52a')
.width(100)
.height(35)
.onClick(() => {
console.info('Button onClick')
if (this.srcData != null) {
this.pageStack.pushDestinationByName("VideoPlayerPage", { item: { video: this.srcData.urls[0], tvurls: this.srcData.tvurls, title: this.srcData.title, desc: this.detailData?.summary } }).catch((e: Error) => {
// 跳转失败,会返回错误码及错误信息
console.log(`catch exception: ${JSON.stringify(e)}`)
}).then(() => {
// 跳转成功
});
} else {
promptAction.showToast({ message: '暂无资源' })
}
})
}.alignItems(HorizontalAlign.Start) // 水平方向靠左对齐
.justifyContent(FlexAlign.Start) // 垂直方向靠上对齐
.padding(10)
}.height(160).width('100%')
Row() {
Text('豆瓣评分').fontSize(16).padding(5)
Rating({ rating: (this.detailData?.rate ?? 0) / 2, indicator: true })
.stars(5)
.stepSize(0.5).height(28)
Text(this.detailData?.rate.toString()).fontColor('#fffab52a').fontWeight(FontWeight.Bold).fontSize(36).padding(5)
}.width('100%').height(80).borderRadius(5).backgroundColor('#f8f4f5').margin(20)
Text('简介').fontSize(18).padding({ bottom: 10 }).fontWeight(FontWeight.Bold).alignSelf(ItemAlign.Start)
Text(this.toggleText).fontSize(14).lineHeight(20).alignSelf(ItemAlign.Start)
Text(this.toggleBtn).fontSize(14).fontColor(Color.Gray).padding(10).alignSelf(ItemAlign.End).onClick(() => {
this.isToggle = !this.isToggle
if (this.isToggle) {
this.toggleBtn = '收起'
this.toggleText = this.description
} else {
this.toggleBtn = '展开'
this.toggleText = this.description.substring(0, 100) + '...'
}
})
Text('影人').fontSize(18).padding({ bottom: 10 }).fontWeight(FontWeight.Bold).alignSelf(ItemAlign.Start)
Scroll() {
Row({ space: 5 }) {
ForEach(this.detailData?.cast, (item: DetailMvRespCast) => {
Column({ space: 0 }) {
Image(item.cover).objectFit(ImageFit.Auto).height(120).borderRadius(5)
.onClick(() => {
})
Text(item.name)
.alignSelf(ItemAlign.Center)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontSize(14).padding(10)
}.justifyContent(FlexAlign.Center)
}, (itm: DetailMvRespCast, idx) => itm.id)
}
}.scrollable(ScrollDirection.Horizontal)
}.padding({ left: 10, right: 10 })
}.title("电影详情")
.width('100%')
.height('100%')
.onReady(ctx => {
this.pageStack = ctx.pathStack
//从上个页面拿参数
this.pageStack.getParamByName("MovieDetailPage")
interface params {
id: string;
}
let par = ctx.pathInfo.param as params
Log.debug("par:%s", par.id)
this.uid = par.id
})
.onShown(() => {
console.info('Detail onShown');
getDetailMv(this.uid).then((res) => {
Log.debug(res.data.message)
Log.debug("request", "res.data.code:%{public}d", res.data.code)
this.detailData = res.data
this.description = this.detailData.summary
this.toggleText = this.description.substring(0, 100) + '...'
}).catch((err: BusinessError) => {
Log.debug("request", "err.data.code:%d", err.code)
Log.debug("request", err.message)
});
getMovieSrc(this.uid).then((res) => {
Log.debug(res.data.message)
Log.debug("request", "res.data.code:%{public}d", res.data.code)
if (res.data.code == 0) {
this.srcData = res.data
}
}).catch((err: BusinessError) => {
Log.debug("request", "err.data.code:%d", err.code)
Log.debug("request", err.message)
});
})
}
}
折叠效果的实现
在电影详情页中,对于电影的简介,使用了折叠效果,即默认只显示部分简介内容,用户点击“展开”按钮后可以查看完整简介。这个效果的实现主要通过控制 Text 组件的显示内容来实现。具体代码如下:
Text(this.toggleText).fontSize(14).lineHeight(20).alignSelf(ItemAlign.Start)
Text(this.toggleBtn).fontSize(14).fontColor(Color.Gray).padding(10).alignSelf(ItemAlign.End).onClick(() => {
this.isToggle = !this.isToggle
if (this.isToggle) {
this.toggleBtn = '收起'
this.toggleText = this.description
} else {
this.toggleBtn = '展开'
this.toggleText = this.description.substring(0, 100) + '...'
}
})
心路历程
从一开始对鸿蒙开发的陌生,到如今能够熟练地完成项目,这背后是无数次的尝试、失败和总结。遇到问题时,我会查阅官方文档,甚至会寻求社区的帮助。每当解决一个问题,都会有一种成就感。通过这个项目,我不仅提升了编程技能,也学会了如何进行项目管理和时间规划。最重要的是,我体验到了项目开发的乐趣,每一次的进步都让我更加自信。
技术突破与职业发展
开发“爱影家”让我在技术上有了显著的突破,尤其是在API数据交互和前端展示方面。更让我高兴的是,这个项目也让我在职业发展上获得了宝贵的经验。现在,我已经能够独立承担一些小项目,甚至帮助一些朋友解决他们遇到的技术难题。开发“爱影家”的过程,无疑是我在鸿蒙开发旅程中最宝贵的一段经历。
结语
“想干什么就去干,干得烂总比不干强!”这句话对我来说意义非凡。也许一开始的作品并不完美,但只要迈出了第一步,未来就会越来越熟练,也就会有成绩有起色。做事情不要想太多,尤其是别太去计较什么意义和得失,开心就好。希望我的开发手记能够激励到更多的鸿蒙开发者,让我们一起踏上鸿蒙之旅,鸿蒙开发相比android太简单了,让鸿蒙生态因你而更加繁荣!