HarmonyOS 实战开发案例-仿抖音短视频应用

136 阅读6分钟

前段时间看到一篇文章,但是没有源码,是一个仿写抖音的文章,最近也在看这块,顺便写个简单的短视频小应用。

技术点拆分

1、http请求数据;

2、measure计算文本宽度;

3、video播放视频;

4、onTouch上滑/下拉切换视频;

5、List实现滚动加载;

效果展示

img

http请求数据

通过对@ohos.net.http进行二次封装,进行数据请求。

1、封装requestHttp;

import http from '@ohos.net.http';

// 1、创建RequestOption.ets 配置类
export interface RequestOptions {
  url?: string;
  method?: RequestMethod; // default is GET
  queryParams ?: Record<string, string>;
  extraData?: string | Object | ArrayBuffer;
  header?: Object; // default is 'content-type': 'application/json'
}

export enum RequestMethod {
  OPTIONS = "OPTIONS",
  GET = "GET",
  HEAD = "HEAD",
  POST = "POST",
  PUT = "PUT",
  DELETE = "DELETE",
  TRACE = "TRACE",
  CONNECT = "CONNECT"
}

/**
 * Http请求器
 */
export class HttpCore {
  /**
   * 发送请求
   * @param requestOption
   * @returns Promise
   */
  request<T>(requestOption: RequestOptions): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.sendRequest(requestOption)
        .then((response) => {
          if (typeof response.result !== 'string') {
            reject(new Error('Invalid data type'));

          } else {
            let bean: T = JSON.parse(response.result);
            if (bean) {
              resolve(bean);
            } else {
              reject(new Error('Invalid data type,JSON to T failed'));
            }

          }
        })
        .catch((error) => {
          reject(error);
        });
    });
  }

  private sendRequest(requestOption: RequestOptions): Promise<http.HttpResponse> {
    // 每一个httpRequest对应一个HTTP请求任务,不可复用
    let httpRequest = http.createHttp();

    let resolveFunction, rejectFunction;
    const resultPromise = new Promise<http.HttpResponse>((resolve, reject) => {
      resolveFunction = resolve;
      rejectFunction = reject;
    });

    if (!this.isValidUrl(requestOption.url)) {
      return Promise.reject(new Error('url格式不合法.'));
    }

    let promise = httpRequest.request(this.appendQueryParams(requestOption.url, requestOption.queryParams), {
      method: requestOption.method,
      header: requestOption.header,
      extraData: requestOption.extraData, // 当使用POST请求时此字段用于传递内容
      expectDataType: http.HttpDataType.STRING // 可选,指定返回数据的类型
    });

    promise.then((response) => {
      console.info('Result:' + response.result);
      console.info('code:' + response.responseCode);
      console.info('header:' + JSON.stringify(response.header));

      if (http.ResponseCode.OK !== response.responseCode) {
        throw new Error('http responseCode !=200');
      }
      resolveFunction(response);

    }).catch((err) => {
      rejectFunction(err);
    }).finally(() => {
      // 当该请求使用完毕时,调用destroy方法主动销毁。
      httpRequest.destroy();
    })
    return resultPromise;
  }


  private appendQueryParams(url: string, queryParams: Record<string, string>): string {
    // todo 使用将参数拼接到url上
    return url;
  }

  private isValidUrl(url: string): boolean {
    //todo 实现URL格式判断
    return true;
  }
}

// 实例化请求器
const httpCore = new HttpCore();


export class HttpManager {
  private static mInstance: HttpManager;

  // 防止实例化
  private constructor() {
  }

  static getInstance(): HttpManager {
    if (!HttpManager.mInstance) {
      HttpManager.mInstance = new HttpManager();
    }
    return HttpManager.mInstance;
  }


  request<T>(option: RequestOptions): Promise<T> {
    return new Promise(async (resolve, reject) => {
      try {
        const data: any = await httpCore.request(option)
        resolve(data)
      } catch (err) {
        reject(err)
      }
    })
  }
}

export default HttpManager;

2、使用requestHttp请求视频接口;

import httpManager, { RequestMethod } from '../../utils/requestHttp';

measure计算文本宽度

import httpManager, { RequestMethod } from '../../utils/requestHttp';

@State total: number = 0
@State listData: Array<ResultType> = []
private url: string = "https://api.apiopen.top/api/getHaoKanVideo?size=10";
private page: number = 0

private httpRequest() {
    httpManager.getInstance()
    .request({
      method: RequestMethod.GET,
      url: `${this.url}&page=${this.page}` //公开的API
    })
      .then((res: resultBean) => {
        this.listData = [...this.listData, ...res.result.list];
        this.total = res.result.total;
        this.duration = 0;
        this.rotateAngle = 0;
      })
      .catch((err) => {
        console.error(JSON.stringify(err));
      });
  }

video播放视频

1、通过videoController控制视频的播放和暂停,当一个视频播放结束,播放下一个

import measure from '@ohos.measure'

@State textWidth : number = measure.measureText({
  //要计算的文本内容,必填
  textContent: this.title,
})
  
// this.textWidth可以获取this.title的宽度

2、Video的一些常用方法

属性:

名称参数类型描述
mutedboolean是否静音。
默认值:false
autoPlayboolean是否自动播放。
默认值:false
controlsboolean控制视频播放的控制栏是否显示。
默认值:true
objectFitImageFit设置视频显示模式。
默认值:Cover
loopboolean是否单个视频循环播放。
默认值:false

事件:

名称功能描述
onStart(event:() => void)播放时触发该事件。
onPause(event:() => void)暂停时触发该事件。
onFinish(event:() => void)播放结束时触发该事件。
onError(event:() => void)播放失败时触发该事件。
onPrepared(callback:(event: { duration: number }) => void)视频准备完成时触发该事件。
duration:当前视频的时长,单位为秒(s)。
onSeeking(callback:(event: { time: number }) => void)操作进度条过程时上报时间信息。
time:当前视频播放的进度,单位为s。
onSeeked(callback:(event: { time: number }) => void)操作进度条完成后,上报播放时间信息。
time:当前视频播放的进度,单位为s。
onUpdate(callback:(event: { time: number }) => void)播放进度变化时触发该事件。
time:当前视频播放的进度,单位为s。
onFullscreenChange(callback:(event: { fullscreen: boolean }) => void)在全屏播放与非全屏播放状态之间切换时触发该事件。
fullscreen:返回值为true表示进入全屏播放状态,为false则表示非全屏播放。

onTouch上滑/下拉切换视频

通过手指按压时,记录Y的坐标,移动过程中,如果移动大于50,则进行上一个视频或者下一个视频的播放。

private onTouch = ((event) => {
  switch (event.type) {
    case TouchType.Down: // 手指按下
      // 记录按下的y坐标
      this.lastMoveY = event.touches[0].y
      break;
    case TouchType.Up: // 手指按下
      this.offsetY = 0
      this.isDone = false
      break;
    case TouchType.Move: // 手指移动
      const offsetY = (event.touches[0].y - this.lastMoveY) * 3;
      let isDownPull = offsetY < -80
      let isUpPull = offsetY > 80
      this.lastMoveY = event.touches[0].y
      if(isUpPull || isDownPull) {
        this.offsetY = offsetY
        this.isDone = true
      }

      console.log('=====offsetY======', this.offsetY, isDownPull, isUpPull)

      if (isDownPull && this.isDone) {
        this.playNext()
      }
      if (isUpPull && this.isDone) {
        this.playNext()
      }
      break;
  }
})

List实现滚动加载

1、由于视频加载会比较慢,因此List中仅展示一个视频的图片,点击播放按钮即可播放;

2、通过onScrollIndex监听滚动事件,如果当前数据和滚动的index小于3,则进行数据下一页的请求;

List({ scroller: this.scroller, space: 12 }) {
  ForEach(this.listData, (item: ResultType, index: number) => {
    ListItem() {
      Stack({ alignContent: Alignment.TopStart }) {
        Row() {
          Image(item.userPic).width(46).height(46).borderRadius(12).margin({ right: 12 }).padding(6)
          Text(item.title || '标题').fontColor(Color.White).width('80%')
        }
        .width('100%')
        .backgroundColor('#000000')
        .opacity(0.6)
        .alignItems(VerticalAlign.Center)
        .zIndex(9)

        Image(item.coverUrl)
          .width('100%')
          .height(320)
          .alt(this.imageDefault)

        Row() {
          Image($rawfile('play.png')).width(60).height(60)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(VerticalAlign.Center)
        .opacity(0.8)
        .zIndex(100)
        .onClick(() => {
          this.currentPlayIndex = index;
          this.coverUrl = item.coverUrl;
          this.playUrl = item.playUrl;
          this.videoController.start()
        })
      }
      .width('100%')
      .height(320)
    }
  })
}
.divider({ strokeWidth: 1, color: 'rgb(247,247,247)', startMargin: 60, endMargin: 0 })
.onScrollIndex((start, end) => {
  console.log('============>', start, end)
  if(this.listData.length - end < 3) {
    this.page = this.page++
    this.httpRequest()
  }
})

完整代码

import httpManager, { RequestMethod } from '../../utils/requestHttp';
import measure from '@ohos.measure'
import router from '@ohos.router';

type ResultType = {
  id: number;
  title: string;
  userName: string;
  userPic: string;
  coverUrl: string;
  playUrl: string;
  duration: string;
}

interface resultBean {
  code: number,
  message: string,
  result: {
    total: number,
    list: Array<ResultType>
  },
}

@Entry
@Component
export struct VideoPlay {
  scroller: Scroller = new Scroller()
  private videoController: VideoController = new VideoController()
  @State total: number = 0
  @State listData: Array<ResultType> = []
  private url: string = "https://api.apiopen.top/api/getHaoKanVideo?size=10";
  private page: number = 0

  private httpRequest() {
    httpManager.getInstance()
    .request({
      method: RequestMethod.GET,
      url: `${this.url}&page=${this.page}` //公开的API
    })
      .then((res: resultBean) => {
        this.listData = [...this.listData, ...res.result.list];
        this.total = res.result.total;
        this.duration = 0;
        this.rotateAngle = 0;
      })
      .catch((err) => {
        console.error(JSON.stringify(err));
      });
  }

  aboutToAppear() {
    this.httpRequest()
  }

  @State currentPlayIndex: number = 0
  @State playUrl: string = ''
  @State coverUrl: string = ''
  @State imageDefault: any = $rawfile('noData.svg')

  @State offsetY: number = 0
  private lastMoveY: number = 0

  playNext() {
    const currentItem = this.listData[this.currentPlayIndex + 1]
    this.currentPlayIndex = this.currentPlayIndex + 1;
    this.coverUrl = currentItem?.coverUrl;
    this.playUrl = currentItem?.playUrl;
    this.videoController.start()
    this.scroller.scrollToIndex(this.currentPlayIndex - 1)

    if(this.listData.length - this.currentPlayIndex < 3) {
      this.page = this.page++
      this.httpRequest()
    }
  }

  playPre() {
    const currentItem = this.listData[this.currentPlayIndex - 1]
    this.currentPlayIndex = this.currentPlayIndex +- 1;
    this.coverUrl = currentItem?.coverUrl;
    this.playUrl = currentItem?.playUrl;
    this.videoController.start()
    this.scroller.scrollToIndex(this.currentPlayIndex - 2)
  }

  private title: string = 'Harmony短视频';
  @State screnWidth: number = 0;
  @State screnHeight: number = 0;
  @State textWidth : number = measure.measureText({
    //要计算的文本内容,必填
    textContent: this.title,
  })
  @State rotateAngle: number = 0;
  @State duration: number = 0;

  private isDone: boolean = false

  @State isPlay: boolean = true

  build() {
    Stack({ alignContent: Alignment.TopEnd }) {
      Row() {
        Stack({ alignContent: Alignment.TopStart }) {
          Button() {
            Image($r('app.media.ic_public_arrow_left')).width(28).height(28).margin({ left: 6, top: 3, bottom: 3 })
          }.margin({ left: 12 }).backgroundColor(Color.Transparent)
          .onClick(() => {
            router.back()
          })
          Text(this.title).fontColor(Color.White).fontSize(18).margin({ top: 6 }).padding({ left: (this.screnWidth - this.textWidth / 3) / 2 })

          Image($r('app.media.ic_public_refresh')).width(18).height(18)
            .margin({ left: this.screnWidth - 42, top: 8 })
            .rotate({ angle: this.rotateAngle })
            .animation({
              duration: this.duration,
              curve: Curve.EaseOut,
              iterations: 1,
              playMode: PlayMode.Normal
            })
            .onClick(() => {
              this.duration = 1200;
              this.rotateAngle = 360;
              this.page = 0;
              this.listData = [];
              this.httpRequest();
            })
        }
      }
      .width('100%')
      .height(60)
      .backgroundColor(Color.Black)
      .alignItems(VerticalAlign.Center)

      if(this.playUrl) {
        Column() {
          Text('')
        }
        .backgroundColor(Color.Black)
        .zIndex(997)
        .width('100%')
        .height('100%')
        if(!this.isPlay) {
          Image($r('app.media.pause')).width(46).height(46)
            .margin({
              right: (this.screnWidth - 32) / 2,
              top: (this.screnHeight - 32) / 2
            })
            .zIndex(1000)
            .onClick(() => {
              this.isPlay = true
              this.videoController.start()
            })
        }

        Image($rawfile('close.png')).width(32).height(32).margin({
          top: 24,
          right: 24
        })
          .zIndex(999)
          .onClick(() => {
            this.videoController.stop()
            this.playUrl = ''
          })
        Video({
          src: this.playUrl,
          previewUri: this.coverUrl,
          controller: this.videoController
        })
          .zIndex(998)
          .width('100%')
          .height('100%')
          .borderRadius(3)
          .controls(false)
          .autoPlay(true)
          .offset({ x: 0, y: `${this.offsetY}px` })
          .onFinish(() => {
            this.playNext()
          })
          .onClick(() => {
            this.isPlay = false
            this.videoController.stop()
          })
          .onTouch((event) => {
            switch (event.type) {
              case TouchType.Down: // 手指按下
                // 记录按下的y坐标
                this.lastMoveY = event.touches[0].y
                break;
              case TouchType.Up: // 手指按下
                this.offsetY = 0
                this.isDone = false
                break;
              case TouchType.Move: // 手指移动
                const offsetY = (event.touches[0].y - this.lastMoveY) * 3;
                let isDownPull = offsetY < -80
                let isUpPull = offsetY > 80
                this.lastMoveY = event.touches[0].y
                if(isUpPull || isDownPull) {
                  this.offsetY = offsetY
                  this.isDone = true
                }

                console.log('=====offsetY======', this.offsetY, isDownPull, isUpPull)

                if (isDownPull && this.isDone) {
                  this.playNext()
                }
                if (isUpPull && this.isDone) {
                  this.playNext()
                }
                break;
            }
          })
      }
      List({ scroller: this.scroller, space: 12 }) {
        ForEach(this.listData, (item: ResultType, index: number) => {
          ListItem() {
            Stack({ alignContent: Alignment.TopStart }) {
              Row() {
                Image(item.userPic).width(46).height(46).borderRadius(12).margin({ right: 12 }).padding(6)
                Text(item.title || '标题').fontColor(Color.White).width('80%')
              }
              .width('100%')
              .backgroundColor('#000000')
              .opacity(0.6)
              .alignItems(VerticalAlign.Center)
              .zIndex(9)

              Image(item.coverUrl)
                .width('100%')
                .height(320)
                .alt(this.imageDefault)

              Row() {
                Image($rawfile('play.png')).width(60).height(60)
              }
              .width('100%')
              .height('100%')
              .justifyContent(FlexAlign.Center)
              .alignItems(VerticalAlign.Center)
              .opacity(0.8)
              .zIndex(100)
              .onClick(() => {
                this.currentPlayIndex = index;
                this.coverUrl = item.coverUrl;
                this.playUrl = item.playUrl;
                this.videoController.start()
              })
            }
            .width('100%')
            .height(320)
          }
          .padding({
            left: 6,
            right: 6,
            bottom: 6
          })
        })
      }
      .width('100%')
      .margin(6)
      .position({ y: 66 })
      .divider({ strokeWidth: 1, color: 'rgb(247,247,247)', startMargin: 60, endMargin: 0 })
      .onScrollIndex((start, end) => {
        console.log('============>', start, end)
        if(this.listData.length - end < 3) {
          this.page = this.page++
          this.httpRequest()
        }
      })
    }
    .onAreaChange((_oldValue: Area, newValue: Area) => {
      this.screnWidth = newValue.width as number;
      this.screnHeight = newValue.height as number;
    })
  }
}

最后分享一份鸿蒙(HarmonyOS)开发学习指南

《鸿蒙(HarmonyOS)开发学习指南》

第一章 快速入门

1、开发准备

2、构建第一个ArkTS应用(Stage模型)

3、构建第一个ArkTS应用(FA模型)

4、构建第一个JS应用(FA模型)

5、........

图片

第二章 开发基础知识

1、应用程序包基础知识

2、应用配置文件(Stage模型)

3、应用配置文件概述(FA模型)

4、.......

图片

第三章 资源分类与访问

1、 资源分类与访问

2、 创建资源目录和资源文件

3、 资源访问

4、.......

图片

第四章 学习ArkTs语言

1、初识ArkTS语言

2、基本语法

3、状态管理

4、其他状态管理

5、渲染控制

6、......

图片

第五章 UI开发

1.方舟开发框架(ArkUI)概述

2.基于ArkTS声明式开发范式

3.兼容JS的类Web开发范式

4.......

图片

第六章 Web开发

1.Web组件概述

2.使用Web组件加载页面

3.设置基本属性和事件

4.在应用中使用前端页面JavaScript

5.ArkTS语言基础类库概述

6.并发

7.......

图片

11.网络与连接

12.电话服务

13.数据管理

14.文件管理

15.后台任务管理

16.设备管理

17......

图片

第七章 应用模型

1.应用模型概述

2.Stage模型开发指导

3.FA模型开发指导

4.......

图片