Skip to content

面试录音

应用权限

应用权限概述

系统提供了一种允许应用访问系统资源(如:通讯录等)和系统能力(如:访问摄像头、麦克风等)的通用权限访问方式,来保护系统数据(包括用户个人数据)或功能,避免它们被不当或恶意使用。

应用申请敏感权限时,必须填写权限使用理由字段,敏感权限通常是指与用户隐私密切相关的权限,包括地理位置、相机、麦克风、日历、健身运动、身体传感器、音乐、文件、图片视频等权限。参考向用户申请授权。

  • system_grant

在配置文件中,声明应用需要请求的权限后,系统会在安装应用时自动为其进行权限预授予,开发者不需要做其他操作即可使用权限。

  • user_grant
    • 在配置文件中,声明应用需要请求的权限,且要设置需要使用的场景+使用原因
    • 调用 requestPermissionsFromUser() 方法后,应用程序将等待用户授权的结果。如果用户授权,则可以继续访问目标操作。如果用户拒绝授权,则需要提示用户必须授权才能访问当前页面的功能,并引导用户到系统应用“设置”中打开相应的权限。可参考二次向用户申请权限 requestPermissionOnSetting()

module.json5

json
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" },
      {
        "name": "ohos.permission.MICROPHONE",
        "usedScene": {},
        "reason": "$string:reason_microphone"
      }
    ],

原因格式:用于xxx模块xxx功能

对所有应用开放权限列表

permission 工具

目标:封装权限工具,提供请求用户权限,拉起用户权限设置的能力

ts
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit';

class Permission {
  // 请求用户授权
  async requestPermissions(permissions: Permissions[]) {
    const atManager = abilityAccessCtrl.createAtManager()
    const ctx = AppStorage.get<Context>('context')
    if (ctx) {
      const result = await atManager.requestPermissionsFromUser(ctx, permissions)
      return result.authResults.every(result => result === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
    }
    return false
  }

  // 打开权限设置 beta3
  async openPermissionSetting(permissions: Permissions[]) {
    const atManager = abilityAccessCtrl.createAtManager()
    const ctx = AppStorage.get<Context>('context')
    if (ctx) {
      const authResults = await atManager.requestPermissionOnSetting(ctx, permissions)
      return authResults.every(result => result === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
    }
    return false
  }
}

export const permission = new Permission()

录音授权

目标:使用权限请求工具,在录音页面实现请求权限,无权限不可进入

ts
permissions: Permissions[] = ['ohos.permission.MICROPHONE']
  confirmConfig:  promptAction.ShowDialogOptions = {
    title: "温馨提示",
    message: "未授权使用麦克风将无法使用该面试录音功能,是否前往设置进行授权?",
    buttons: [
      { text: '离开', color: $r('app.color.common_gray_01') },
      { text: '去授权', color: $r('app.color.black') }
    ]
  }

  async getPermission () {
    try {
      // 第一请求授权
      const isOk = await permission.requestPermissions(this.permissions)
      if (isOk) return
      // 弹窗提示
      const confirm = await promptAction.showDialog(this.confirmConfig)
      if (confirm.index === 1) {
        const isOk2 = await permission.openPermissionSetting(this.permissions)
        if (isOk2) return
      }
      router.back()
    } catch (e) {
      promptAction.showToast({ message: '未授权' })
      router.back()
    }
  }

  aboutToAppear(): void {
    this.getPermission()
  }

录音知识

使用 AvRecorder 录音

目标:使用 AvRecorder 实现音频录制存储到应用沙箱

alt text

实现步骤:

  • 需要一个文件接收音频数据
  • 准备录音配置
  • 使用 AvRecorder 实现开始录音,结束录音

落地代码:

ts
avRecorder?: media.AVRecorder
  fd?: number
  filePath?: string

  async startRecord() {
    // 1. 准备一个文件接收录音
    const ctx = getContext(this)
    const filePath = ctx.filesDir + '/' + Date.now() + '.m4a'
    this.filePath = filePath
    const file = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
    this.fd = file.fd
    // 2. 准备路由配置对象
    const config: media.AVRecorderConfig = {
      audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
      profile: {
        audioBitrate: 100000, // 音频比特率
        audioChannels: 1, // 音频声道数
        audioCodec: media.CodecMimeType.AUDIO_AAC, // 音频编码格式,当前只支持aac
        audioSampleRate: 48000, // 音频采样率
        fileFormat: media.ContainerFormatType.CFT_MPEG_4A, // 封装格式,当前只支持m4a
      },
      url: `fd://${file.fd}`
    }
    // 3. 开始录制
    const avRecorder = await media.createAVRecorder()
    await avRecorder.prepare(config)
    await avRecorder.start()
    this.avRecorder = avRecorder
  }

  async stopRecord() {
    if (this.avRecorder) {
      await this.avRecorder.stop()
      await this.avRecorder.release()
      fileIo.closeSync(this.fd)
    }
  }
ts
 Button('开始录音')
        .onClick(() => {
          this.startRecord()
        })
      Button('结束录音')
        .onClick(() => {
          this.stopRecord()
        })

录音声音振动效果

目标:根据声音的大小实现声音振动特效

alt text

实现步骤:

  • 通过 getAudioCapturerMaxAmplitude 观察音频区间
  • 封装振动组件,通过声音振幅数据实现振动效果

落地代码:

1)获取振幅数据,出入振动组件 AudioPage.ets

ts
  timer?: number
  @State maxAmplitude: number = 0
ts
    // startRecord 4. 每100ms获取一下声音振幅
    this.timer = setInterval(async () => {
      this.maxAmplitude = await avRecorder.getAudioCapturerMaxAmplitude()
      logger.debug('startRecord', this.maxAmplitude.toString())
    }, 100)
ts
    // stopRecord 清理定时器
    clearInterval(this.timer)
ts
AudioBoComp({ maxAmplitude: this.maxAmplitude })

2)实现振动组件 Audio/AudioBoComp.ets

ts
@Component
struct AudioBoComp {
  @Prop @Watch('onChange') maxAmplitude: number
  @State per: number = 0
  onChange() {
    animateTo({ duration: 100 }, () => {
      if (this.maxAmplitude < 500) {
        this.per = 0
      } else if (this.maxAmplitude > 30000) {
        this.per = 1
      } else {
        this.per = this.maxAmplitude / 30000
      }
    })
  }

  build() {
    Row({ space: 5 }) {
      ForEach(Array.from({ length: 30 }), () => {
        Column()
          .layoutWeight(1)
          .height(this.per * 100 * Math.random())
          .backgroundColor($r('app.color.common_blue'))
      })
    }
    .width('100%')
    .height(100)
  }
}

使用 AvPlayer 播放

目标:能够使用 AvPlayer 播放应用沙箱中的音频文件,且显示进度条

落地代码:

ts
  avPlayer?: media.AVPlayer
  @State total: number = 0
  @State value: number = 0

  async startPlay() {
    try {
      const file = fileIo.openSync(this.filePath, fileIo.OpenMode.READ_ONLY)
      const avPlayer = await media.createAVPlayer()
      avPlayer.on('stateChange', state => {
        if (state === 'initialized') {
          avPlayer.prepare()
        } else if ( state === 'prepared') {
          avPlayer.loop = true
          this.total = avPlayer.duration
          avPlayer.play()
        }
      })
      // 当前播放时间改变
      avPlayer.on('timeUpdate', (time) => {
        this.value = time
      })
      avPlayer.url = `fd://${file.fd}`
      this.avPlayer = avPlayer
    } catch (e) {
      logger.error('startPlay', JSON.stringify(e))
    }
  }

  stopPlay() {
    if (this.avPlayer) {
      this.avPlayer.stop()
      this.avPlayer.release()
    }
  }
ts
     Button('开始播放')
        .onClick(() => {
          this.startPlay()
        })
      Button('停止播放')
        .onClick(() => {
          this.stopPlay()
        })
      Progress({ total: this.total, value: this.value })
        .width('100%')

关系型数据库知识

数据库概述

关系型数据库(Relational Database,RDB)是一种基于关系模型来管理数据的数据库。关系型数据库基于SQLite组件提供了一套完整的对本地数据库进行管理的机制,对外提供了一系列的增、删、改、查等接口,也可以直接运行用户输入的SQL语句来满足复杂的场景需要。不支持Worker线程。

ArkTS侧支持的基本数据类型:number、string、二进制类型数据、boolean。为保证插入并读取数据成功,建议一条数据不要超过2M。超出该大小,插入成功,读取失败。

该模块提供以下关系型数据库相关的常用功能:

  • RdbStore:提供管理关系数据库(RDB)方法的接口。
  • RdbPredicates:数据库中用来代表数据实体的性质、特征或者数据实体之间关系的词项,主要用来定义数据库的操作条件。
  • ResultSet:提供用户调用关系型数据库查询接口之后返回的结果集合。

alt text

创建数据库

alt text

sql
CREATE TABLE IF NOT EXISTS article (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
create_time INTEGER NOT NULL
)

创建一个文章数据库:

ts
store?: relationalStore.RdbStore
  tableName = 'article'

  async createStore () {
    const store = await relationalStore.getRdbStore(getContext(this), {
      name: 'interview_tong.db',
      securityLevel: relationalStore.SecurityLevel.S1
    })
    store.executeSql(`
        CREATE TABLE IF NOT EXISTS ${this.tableName} (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          title TEXT NOT NULL,
          content TEXT NOT NULL,
          create_time INTEGER NOT NULL
        )
      `)
    this.store = store
  }

  aboutToAppear(): void {
    this.createStore()
  }

插入数据

ts
      Button('添加')
        .onClick(() => {
          this.store?.insert(this.tableName, {
            id: null,
            title: '测试' + Math.random(),
            content: '我是一篇测试文章' + Math.random(),
            create_time: Date.now()
          })
        })

查询数据

ts
Button('查询总条数')
        .onClick(async () => {
          const predicates = new relationalStore.RdbPredicates(this.tableName)
          const resultSet = await this.store?.query(predicates)
          this.total = resultSet?.rowCount || 0
        })

      Text('总条数' + this.total)

      Button('查询所有数据')
        .onClick(async () => {
          const predicates = new relationalStore.RdbPredicates(this.tableName)
          const resultSet = await this.store?.query(predicates)
          const list: ArticleItem[] = []
          while (resultSet?.goToNextRow()) {
            list.push({
              id: resultSet.getLong(resultSet.getColumnIndex('id')),
              title: resultSet.getString(resultSet.getColumnIndex('title')),
              content: resultSet.getString(resultSet.getColumnIndex('content')),
              create_time: resultSet.getLong(resultSet.getColumnIndex('create_time'))
            })
          }
          resultSet?.close()

          this.list = list
        })

      Text(JSON.stringify(this.list))

修改数据

ts
 Button('修改第一条')
        .onClick(() => {
          const item = this.list[0]
          item.title = '修改标题' + Math.random()

          const predicates = new relationalStore.RdbPredicates(this.tableName)
          predicates.equalTo('id', item.id)
           this.store?.updateSync(item, predicates)
        })

删除数据

ts
 Button('删除第一条')
        .onClick(() => {
          const item = this.list[0]
          const predicates = new relationalStore.RdbPredicates(this.tableName)
          predicates.equalTo('id', item.id)
          this.store?.deleteSync(predicates)
        })

删除数据库

ts
  deleteStore () {
    relationalStore.deleteRdbStore(getContext(this), {
      name: 'interview.db',
      securityLevel: relationalStore.SecurityLevel.S1
    })
  }
ts
      Button('删除数据库')
        .onClick(() => {
          this.deleteStore()
        })

audioDB工具-创建数据库

目标:封装一个操作录音数据库的工具,提供创建数据库的方法

实现步骤:

  • 约定好数据库的表结构
  • 封装工具类,提供一个创建数据库的方法

落地代码:

1)表结构

SQL
CREATE TABLE IF NOT EXISTS interview_audio (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  name TEXT NOT NULL,
  path TEXT NOT NULL,
  duration INTEGER NOT NULL,
  size INTEGER NOT NULL
)

2)封装类

ts
import { relationalStore } from '@kit.ArkData'

class AudioDB {
  store?: relationalStore.RdbStore
  tableName = 'interview_audio'

  // 初始化数据库
  async initStore() {
    const ctx = AppStorage.get<Context>('context')
    if (ctx) {
      const store = await relationalStore.getRdbStore(ctx, {
        name: 'interview_audio.db',
        securityLevel: relationalStore.SecurityLevel.S1
      })
      const sql = `
        CREATE TABLE IF NOT EXISTS ${this.tableName} (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          name TEXT NOT NULL,
          path TEXT NOT NULL,
          duration INTEGER NOT NULL,
          size INTEGER NOT NULL
        )
      `
      await store.executeSql(sql)
      this.store = store
    }
  }
}

audioDB工具-数据操作方法

目标:提供 添加 删除 查询 修改 数据库的方法

ts
import { relationalStore, ValuesBucket } from '@kit.ArkData'

export interface InterviewAudioItem extends ValuesBucket {
  id: number | null
  user_id: string
  name: string
  path: string
  duration: number
  size: number
  create_time: number
}

class AudioDB {
  store?: relationalStore.RdbStore
  tableName = 'interview_audio'

  // 初始化数据库
  async initStore() {
    const ctx = AppStorage.get<Context>('context')
    if (ctx) {
      const store = await relationalStore.getRdbStore(ctx, {
        name: 'interview_audio.db',
        securityLevel: relationalStore.SecurityLevel.S1
      })
      const sql = `
        CREATE TABLE IF NOT EXISTS ${this.tableName} (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          name TEXT NOT NULL,
          path TEXT NOT NULL,
          duration INTEGER NOT NULL,
          size INTEGER NOT NULL,
          create_time INTEGER NOT NULL
        )
      `
      await store.executeSql(sql)
      this.store = store
    }
  }

  // 添加
  async insert(item: InterviewAudioItem) {
    const rowId = await this.store?.insert(this.tableName, item)
    if (rowId === undefined || rowId === -1) {
      return Promise.reject('insert fail')
    } else {
      return Promise.resolve()
    }
  }

  // 删除
  async delete(id: number) {
    const predicates = new relationalStore.RdbPredicates(this.tableName)
    predicates.equalTo('id', id)
    const rowCount = await this.store?.delete(predicates)
    if (rowCount === undefined || rowCount <= 0) {
      return Promise.reject('delete fail')
    } else {
      return Promise.resolve()
    }
  }

  // 修改
  async update(item: InterviewAudioItem) {
    const predicates = new relationalStore.RdbPredicates(this.tableName)
    predicates.equalTo('id', item.id)
    const rowCount = await this.store?.update(item, predicates)
    if (rowCount === undefined || rowCount <= 0) {
      return Promise.reject('update fail')
    } else {
      return Promise.resolve()
    }
  }

  // 查询用户
  async query(userId: string) {
    const predicates = new relationalStore.RdbPredicates(this.tableName)
    predicates.equalTo('user_id', userId)
    const resultSet = await this.store?.query(predicates)
    if (!resultSet) {
      return Promise.reject('query fail')
    }
    const list: InterviewAudioItem[] = []
    while (resultSet.goToNextRow()) {
      list.push({
        id: resultSet.getLong(resultSet.getColumnIndex('id')),
        user_id: resultSet.getString(resultSet.getColumnIndex('user_id')),
        name: resultSet.getString(resultSet.getColumnIndex('name')),
        path: resultSet.getString(resultSet.getColumnIndex('path')),
        duration: resultSet.getLong(resultSet.getColumnIndex('duration')),
        size: resultSet.getLong(resultSet.getColumnIndex('size')),
        create_time: resultSet.getLong(resultSet.getColumnIndex('create_time'))
      })
    }
    resultSet.close()
    return Promise.resolve(list)
  }
}

export const audioDB = new AudioDB()

面试录音

页面结构

目的:准备页面的组件结构,搭建页面基本效果

pages/AudioPage.ets

ts
import { permission } from '../commons/utils'
import { promptAction, router } from '@kit.ArkUI'
import { Permissions } from '@kit.AbilityKit'
import { AudioView } from '../views/Audio/AudioView'

@Entry
@Component
struct AudioPage {
  permissions: Permissions[] = ['ohos.permission.MICROPHONE']
  confirmConfig: promptAction.ShowDialogOptions = {
    title: "温馨提示",
    message: "未授权使用麦克风将无法使用该面试录音功能,是否前往设置进行授权?",
    buttons: [
      { text: '离开', color: $r('app.color.common_gray_01') },
      { text: '去授权', color: $r('app.color.black') }
    ]
  }

  async getPermission() {
    try {
      // 第一请求授权
      const isOk = await permission.requestPermissions(this.permissions)
      if (isOk) {
        return
      }
      // 未授权弹窗提示
      const confirm = await promptAction.showDialog(this.confirmConfig)
      if (confirm.index === 1) {
        // 第二次请求权限
        const isOk2 = await permission.openPermissionSetting(this.permissions)
        if (isOk2) {
          return
        }
      }
      router.back()
    } catch (e) {
      promptAction.showToast({ message: '未授权' })
      router.back()
    }
  }

  build() {
    Column() {
      AudioView()
    }
  }
}

views/AudioView.ets 录音视图

ts
import { HcNavBar } from '../../commons/components/HcNavBar'
import { InterviewAudioItem } from '../../commons/utils'
import { AudioItemComp } from './AudioItemComp'
import { AudioRecordComp } from './AudioRecordComp'

@Component
export struct AudioView {
  @State list: InterviewAudioItem[] = [{} as InterviewAudioItem, {} as InterviewAudioItem ] 

  build() {
    Column() {
      HcNavBar({ title: '面试录音', showRightIcon: false })
      Column() {
        List() {
          ForEach(this.list, (item: InterviewAudioItem) => {
            ListItem() {
              AudioItemComp({
                item: {
                  id: 1,
                  name: '2024年10月01日_10点10分10秒',
                  path: '/data/el/xxx',
                  user_id: 100,
                  duration: 10000,
                  size: 10000,
                  create_time: 10000
                }
              })
            }
          })
        }
        .width('100%')
        .height('100%')
      }
      .width('100%')
      .layoutWeight(1)

      AudioRecordComp()
    }
    .width('100%')
    .height('100%')
  }
}

views/AudioItemComp.ets 单条录音数据数组

ts
import { InterviewAudioItem } from '../../commons/utils'

@Component
export struct AudioItemComp {
  @Prop
  item: InterviewAudioItem = {} as InterviewAudioItem

  build() {
    Row({ space: 15 }) {
      Image($r('app.media.ic_mine_audio'))
        .width(50)
        .aspectRatio(1)
      Column({ space: 10 }) {
        Text(this.item.name)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Row({ space: 20 }) {
          Text(`时长:${(this.item.duration / 1000).toFixed(0)} 秒`)
            .fontSize(14)
            .fontColor($r('app.color.common_gray_03'))
          Text(`大小:${(this.item.size / 1000).toFixed(0)} KB`)
            .fontSize(14)
            .fontColor($r('app.color.common_gray_03'))
        }
        .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .alignSelf(ItemAlign.Start)
    }
    .padding(15)
    .height(80)
    .width('100%')
  }
}

views/AudioRecordComp.ets 录音组件

ts
import { media } from '@kit.MediaKit'
import { fileIo } from '@kit.CoreFileKit'

@Component
export struct AudioRecordComp {
  @StorageProp('bottomHeight') bottomHeight: number = 0

  avRecorder?: media.AVRecorder
  fd?: number
  filePath?: string
  timer?: number
  @State maxAmplitude: number = 0

  async startRecord() {
    // 1. 准备一个文件接收录音
    const ctx = getContext(this)
    const filePath = ctx.filesDir + '/' + Date.now() + '.m4a'
    this.filePath = filePath
    const file = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
    this.fd = file.fd
    // 2. 准备路由配置对象
    const config: media.AVRecorderConfig = {
      audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
      profile: {
        audioBitrate: 100000, // 音频比特率
        audioChannels: 1, // 音频声道数
        audioCodec: media.CodecMimeType.AUDIO_AAC, // 音频编码格式,当前只支持aac
        audioSampleRate: 48000, // 音频采样率
        fileFormat: media.ContainerFormatType.CFT_MPEG_4A, // 封装格式,当前只支持m4a
      },
      url: `fd://${file.fd}`
    }
    // 3. 开始录制
    const avRecorder = await media.createAVRecorder()
    await avRecorder.prepare(config)
    await avRecorder.start()
    this.avRecorder = avRecorder
    // 4. 每100ms获取一下声音振幅
    this.timer = setInterval(async () => {
      this.maxAmplitude = await avRecorder.getAudioCapturerMaxAmplitude()
    }, 100)
  }

  async stopRecord() {
    if (this.avRecorder) {
      clearInterval(this.timer)
      await this.avRecorder.stop()
      await this.avRecorder.release()
      fileIo.closeSync(this.fd)
      this.maxAmplitude = 0
    }
  }

  build() {
    Column() {
      AudioBoComp({ maxAmplitude: this.maxAmplitude })
      Row() {
        Image($r('sys.media.ohos_ic_public_voice'))
          .width(24)
          .aspectRatio(1)
          .fillColor($r('app.color.white'))
          .onClick(async () => {
            // TODO 开始和停止录音
          })
      }
      .justifyContent(FlexAlign.Center)
      .height(50)
      .width(50)
      .borderRadius(25)
      .margin({ top: 20 })
      .backgroundColor($r('app.color.black'))
    }
    .width('100%')
    .height(240)
    .backgroundColor($r('app.color.common_gray_bg'))
    .padding({ bottom: this.bottomHeight, left: 80, right: 80, top: 20 })
  }
}

@Component
export struct AudioBoComp {
  @Prop @Watch('onChange') maxAmplitude: number
  @State per: number = 0

  onChange() {
    animateTo({ duration: 100 }, () => {
      if (this.maxAmplitude < 500) {
        this.per = 0
      } else if (this.maxAmplitude > 30000) {
        this.per = 1
      } else {
        this.per = this.maxAmplitude / 30000
      }
    })
  }

  build() {
    Row({ space: 5 }) {
      ForEach(Array.from({ length: 30 }), () => {
        Column()
          .layoutWeight(1)
          .height(this.per * 100 * Math.random())
          .backgroundColor($r('app.color.common_blue'))
      })
    }
    .width('100%')
    .height(100)
    .backgroundColor($r('app.color.common_gray_bg'))
  }
}

添加录音

目标:点击录音按钮开启录音,再次点击结束录音,存储录音信息

落地代码:

1)组件实现录制状态切换 views/AudioRecordComp.ets

ts
 @State recording: boolean = false
ts
Image($r('sys.media.ohos_ic_public_voice'))
          .width(24)
          .aspectRatio(1)
          .fillColor($r('app.color.white'))
          .onClick(async () => {
            if (this.recording) {
              await this.stopRecord()
              this.recording = false
              // TODO 记录录音
            } else {
              await this.startRecord()
              this.recording = true
            }
          })

2)组件暴露录制结束事件

ts
  onRecordEnd: (item: InterviewAudioItem) => void = () => {}
ts
            const stat = fileIo.statSync(this.filePath)
              this.onRecordEnd({
                id: null,
                name: dayjs().format('YYYY年MM月DD日_HH时mm分ss秒'),
                path  : this.filePath || '',
                duration:  Date.now() - this.startTime,
                size: stat.size,
                user_id: auth.getUser().id,
                create_time: Date.now()
              })

3)父组件在录制结束后,插入数据库完成添加

AudioView.ets

ts
  async aboutToAppear() {
    await audioDB.initStore()
  }
ts
    AudioRecordComp({
      onRecordEnd: async (item: InterviewAudioItem) => {
        await audioDB.insert(item)
        // TODO 更新列表
      }
    })

渲染列表

目标:完成录音列表展示

1)获取数据库录音数据

ts
  async getList() {
    const user = auth.getUser()
    const rows = await audioDB.query(user.id)
    this.list = rows
  }
ts
  async aboutToAppear() {
    await audioDB.initStore()
    await this.getList()
  }

2)渲染列表

ts
ForEach(this.list, (item: InterviewAudioItem) => {
  ListItem() {
    AudioItemComp({
      item: item
    })
  }
})

删除录音

目标:通过滑动操作完成录音删除

1)准备滑动删除和编辑效果

ts
@Builder
  ListItemSwiperBuilder(item: InterviewAudioItem) {
    Row() {
      Text('编辑')
        .actionButton($r('app.color.common_blue'))
      Text('删除')
        .actionButton('#FF0033')
    }
    .height('100%')
  }
ts
@Extend(Text)
function actionButton(color: ResourceColor) {
  .width(80)
  .aspectRatio(1)
  .backgroundColor(color)
  .textAlign(TextAlign.Center)
  .fontColor($r('app.color.white'))
}
ts
ListItem() {
  AudioItemComp({
    item: item
  })
}
.swipeAction({
  end: this.ListItemSwiperBuilder(item)
})

2)实现删除

ts
      Text('删除')
        .actionButton('#FF0033')
        .onClick(async () => {
          await audioDB.delete(item.id!)
          this.getList()
        })

编辑录音

目标:实现弹窗对话框,修改录音名称

1)准备对话框

ts

@CustomDialog
struct InputDialog {
  controller: CustomDialogController
  @Prop name: string = ''
  onSubmit: (name: string) => void = () => {
  }

  build() {
    Column({ space: 12 }) {
      Text('修改名字:')
        .height(40)
        .fontWeight(500)
      TextInput({ text: $$this.name })
      Row({ space: 120 }) {
        Text('取消')
          .fontWeight(500)
          .fontColor($r('app.color.common_gray_02'))
          .onClick(() => {
            this.controller.close()
          })
        Text('确认')
          .fontWeight(500)
          .fontColor($r('app.color.common_blue'))
          .onClick(() => {
            this.onSubmit(this.name)
          })
      }
      .height(40)
      .width('100%')
      .justifyContent(FlexAlign.Center)
    }
    .alignItems(HorizontalAlign.Start)
    .padding(16)
    .borderRadius(12)
    .width('80%')
    .backgroundColor($r('app.color.white'))
  }
}

2)弹出对话框

ts
  @State currentItem: InterviewAudioItem = {} as InterviewAudioItem
ts
dialog = new CustomDialogController({
    builder: InputDialog({
      name: this.currentItem.name,
      onSubmit: async (name) => {
        // TODO 实现修改
      }
    }),
    customStyle: true,
    alignment: DialogAlignment.Center
  })
ts
    Row() {
      Text('编辑')
        .actionButton($r('app.color.common_blue'))
        .onClick(() => {
          this.currentItem = item
          this.dialog.open()
        })

3)完成修改

ts
  dialog = new CustomDialogController({
    builder: InputDialog({
      name: this.currentItem.name,
      onSubmit: async (name) => {
        const item = this.currentItem
        item.name = name
        await audioDB.update(item)
        await this.getList()
        this.dialog.close()
      }
    }),
    customStyle: true,
    alignment: DialogAlignment.Center
  })

录音播放

目标:通过全屏模态框实现录音信息展示和播放

1)播放组件准备 views/AudioPlayer.ets 支持播放暂停和进度效果

ts
import { InterviewAudioItem, logger } from '../../commons/utils'
import { media } from '@kit.MediaKit'
import { fileIo } from '@kit.CoreFileKit'

@Component
export struct AudioPlayer {
  @State
  playing: boolean = false
  @Prop item: InterviewAudioItem = {} as InterviewAudioItem
  avPlayer?: media.AVPlayer
  @State total: number = 0
  @State value: number = 0

  async startPlay() {
    try {
      const file = fileIo.openSync(this.item.path, fileIo.OpenMode.READ_ONLY)
      const avPlayer = await media.createAVPlayer()
      avPlayer.on('stateChange', state => {
        if (state === 'initialized') {
          avPlayer.prepare()
        } else if (state === 'prepared') {
          avPlayer.loop = true
          this.total = avPlayer.duration
          avPlayer.play()
        }
      })
      // 当前播放时间改变
      avPlayer.on('timeUpdate', (time) => {
        this.value = time
      })
      avPlayer.url = `fd://${file.fd}`
      this.avPlayer = avPlayer
      this.playing = true
    } catch (e) {
      logger.error('startPlay', JSON.stringify(e))
    }
  }

  stopPlay() {
    if (this.avPlayer) {
      this.avPlayer.stop()
      this.avPlayer.release()
      this.playing = false
    }
  }

  aboutToAppear(): void {
    if (this.playing) {
      this.stopPlay()
    }
  }

  build() {
    Column({ space: 20 }) {
      Image($r('app.media.ic_mine_audio'))
        .width(100)
        .aspectRatio(1)
      Text(this.item.name)
        .fontSize(18)

      Row({ space: 20 }) {
        Image(!this.playing ? $r('sys.media.ohos_ic_public_play') : $r('sys.media.ohos_ic_public_pause'))
          .width(24)
          .aspectRatio(1)
          .onClick(() => {
            if (!this.playing) {
              this.startPlay()
            } else {
              this.stopPlay()
            }
          })
        Progress({ value: this.value, total: this.total })
          .layoutWeight(1)
          .margin({ top: 20, bottom: 20 })
      }
      .width('80%')
    }
    .justifyContent(FlexAlign.Center)
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.white'))
    .onDisAppear(() => {
      this.stopPlay()
    })
  }
}

2)绑定全屏模态框

ts
  @Builder
  PlayerBuilder () {
    Column(){
      AudioPlayer({ item: this.currentItem })
    }
  }
ts
    .width('100%')
    .height('100%')
    .bindContentCover($$this.isShow, this.PlayerBuilder())
ts
              AudioItemComp({
                item: item
              })
                .onClick(() => {
                  this.currentItem = item
                  this.isShow = true
                })

Released under the Apache-2.0 License.