HarmonyOS NEXT 免费无广告看电影app:从想法到实现的经验总结
学习一项新技能,最好也是最快的方法就是动手实战。学习鸿蒙也一样,给自己定一个小目标,直接找项目练,这样进步是最快的。最近,我在网上看到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.isToggleif (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 paramsLog.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.datathis.description = this.detailData.summarythis.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.isToggleif (this.isToggle) {this.toggleBtn = '收起'this.toggleText = this.description} else {this.toggleBtn = '展开'this.toggleText = this.description.substring(0, 100) + '...'}
})
心路历程
从一开始对鸿蒙开发的陌生,到如今能够熟练地完成项目,这背后是无数次的尝试、失败和总结。遇到问题时,我会查阅官方文档,甚至会寻求社区的帮助。每当解决一个问题,都会有一种成就感。通过这个项目,我不仅提升了编程技能,也学会了如何进行项目管理和时间规划。最重要的是,我体验到了项目开发的乐趣,每一次的进步都让我更加自信。
技术突破与职业发展
开发“爱影家”让我在技术上有了显著的突破,尤其是在API数据交互和前端展示方面。更让我高兴的是,这个项目也让我在职业发展上获得了宝贵的经验。现在,我已经能够独立承担一些小项目,甚至帮助一些朋友解决他们遇到的技术难题。开发“爱影家”的过程,无疑是我在鸿蒙开发旅程中最宝贵的一段经历。
结语
“想干什么就去干,干得烂总比不干强!”这句话对我来说意义非凡。也许一开始的作品并不完美,但只要迈出了第一步,未来就会越来越熟练,也就会有成绩有起色。做事情不要想太多,尤其是别太去计较什么意义和得失,开心就好。希望我的开发手记能够激励到更多的鸿蒙开发者,让我们一起踏上鸿蒙之旅,鸿蒙开发相比android太简单了,让鸿蒙生态因你而更加繁荣!