本文是系列文章,其他文章见:
敲鸿蒙木鱼,积____功德🐶🐶🐶——鸿蒙元服务开发:从入门到放弃(1)
敲鸿蒙木鱼,积____功德🐶🐶🐶——鸿蒙元服务开发:从入门到放弃(2)

本文完整源码查看funny-widget

简介

因为工作需要,准备开发元服务,所以就想着搞一个电子木鱼的DEMO学习一下元服务以及桌面卡片的功能开发知识。

详细了解HarmonyOS的元服务,可查看官方介绍

涉及知识点

  • 元服务开发流程
  • 加载图片
  • 播放音频
  • 开发调试
  • 组件代码在卡片和元服务间共享
  • 数据在卡片和元服务间共享

应用内卡片开发

因为元服务卡片存在音频播放问题,在咨询了官方技术支持后,确定是无法播放的。

在官方文档中看到了使用call事件拉起指定UIAbility到后台

因此使用了此方法进行音频播放功能验证。

卡片代码

@Entry
@Component
struct WidgetEventCallCard {
  @LocalStorageProp('formId') formId: string = '12400633174999288';

  build() {
    Column() {
      Row() {
        Column() {
          Button() {
            Text('playLocalSound')
              .padding(16)
          }
          .onClick(() => {
            console.info('click playLocalSound')
            postCardAction(this, {
              action: 'call',
              abilityName: 'WidgetEventCallEntryAbility', // 只能跳转到当前应用下的UIAbility
              params: {
                formId: '12400633174999288',
                method: 'playLocalSound' // 在EntryAbility中调用的方法名
              }
            });
            console.info('after click playLocalSound')
          })
          Button() {
            Text('playOnlineSound')
              .padding(16)
          }
          .onClick(() => {
            console.info('click playOnlineSound')
            postCardAction(this, {
              action: 'call',
              abilityName: 'WidgetEventCallEntryAbility', // 只能跳转到当前应用下的UIAbility
              params: {
                formId: '12400633174999288',
                method: 'playOnlineSound' // 在EntryAbility中调用的方法名
              }
            });
            console.info('after click playOnlineSound')
          })
        }
      }.width('100%').height('80%')
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .height('100%')
    .alignItems(HorizontalAlign.Center)
  }
}

卡片上添加了两个按钮分别用来测试本地音频播放和在线音频播放。

Entry代码

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { promptAction, window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { rpc } from '@kit.IPCKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { media } from '@kit.MediaKit';
import { AudioManager } from '../utils/AudioManager';

const TAG: string = 'WidgetEventCallEntryAbility';
const DOMAIN_NUMBER: number = 0xFF00;
const CONST_NUMBER_1: number = 1;
const CONST_NUMBER_2: number = 2;

class MyParcelable implements rpc.Parcelable {
  num: number;
  str: string;

  constructor(num: number, str: string) {
    this.num = num;
    this.str = str;
  }

  marshalling(messageSequence: rpc.MessageSequence): boolean {
    messageSequence.writeInt(this.num);
    messageSequence.writeString(this.str);
    return true;
  }

  unmarshalling(messageSequence: rpc.MessageSequence): boolean {
    this.num = messageSequence.readInt();
    this.str = messageSequence.readString();
    return true;
  }
}

export default class WidgetEventCallEntryAbility extends UIAbility {
  // 如果UIAbility第一次启动,在收到call事件后会触发onCreate生命周期回调
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      // 监听call事件所需的方法
      this.callee.on('playLocalSound', (data: rpc.MessageSequence) => {
        // 获取call事件中传递的所有参数
        hilog.info(DOMAIN_NUMBER, TAG, `playLocalSound param:  ${JSON.stringify(data.readString())}`);
        AudioManager.shared.playSound()
        return new MyParcelable(CONST_NUMBER_1, 'aaa');
      });
      this.callee.on('playOnlineSound', (data: rpc.MessageSequence) => {
        // 获取call事件中传递的所有参数
        hilog.info(DOMAIN_NUMBER, TAG, `playOnlineSound param:  ${JSON.stringify(data.readString())}`);
        AudioManager.shared.playOnlineSound()
        return new MyParcelable(CONST_NUMBER_1, 'aaa');
      });
    } catch (err) {
      hilog.error(DOMAIN_NUMBER, TAG, `Failed to register callee on. Cause: ${JSON.stringify(err as BusinessError)}`);
    }
  }

  // 进程退出时,解除监听
  onDestroy(): void | Promise<void> {
    try {
      this.callee.off('playLocalSound');
      this.callee.off('playOnlineSound');
    } catch (err) {
      hilog.error(DOMAIN_NUMBER, TAG, `Failed to register callee off. Cause: ${JSON.stringify(err as BusinessError)}`);
    }
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // Main window is created, set main page for this ability
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
        return;
      }
      hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
    });
  }

  onWindowStageDestroy(): void {
    // Main window is destroyed, release UI related resources
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
  }

  onForeground(): void {
    // Ability has brought to foreground
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground');
  }

  onBackground(): void {
    // Ability has back to background
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground');
  }
}

主要就是在onCreate方法中监听playLocalSoundplayOnlineSound两个call事件。

AudioManager

import { media } from '@kit.MediaKit'
import { BusinessError } from '@kit.BasicServicesKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { common } from '@kit.AbilityKit'

export class AudioManager {
  static shared = new AudioManager()

  playSound() {
    this.log(`AudioManager playSound`)
    this.playLocalSound()
  }

  log(message: string) {
    hilog.info(0x0000, '音频', '%{public}s', `${message}`);
    console.info(`[音频]${message}`);
  }

  isSeek: boolean = false
  count: number = 0
  setAVPlayerCallback(avPlayer: media.AVPlayer) {
    this.log('setAVPlayerCallback')
    // seek操作结果回调函数
    avPlayer.on('seekDone', (seekDoneTime: number) => {
      this.log(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`)
    })
    // error回调监听函数,当avPlayer在操作过程中出现错误时调用 reset接口触发重置流程
    avPlayer.on('error', (err: BusinessError) => {
      this.log(`avPlayer on error, code is ${err.code}, message is ${err.message}`)
      avPlayer.reset(); // 调用reset重置资源,触发idle状态
    })
    // 状态机变化回调函数
    avPlayer.on('stateChange', async (state: media.AVPlayerState, reason: media.StateChangeReason) => {
      this.log(`stateChange回调:${state}${reason.toString()}`)
      switch (state) {
        case 'idle': // 成功调用reset接口后触发该状态机上报
          console.info('AVPlayer state idle called.');
          avPlayer.release(); // 调用release接口销毁实例对象
          break;
        case 'initialized': // avplayer 设置播放源后触发该状态上报
          console.info('AVPlayer state initialized called.');
          avPlayer.prepare();
          break;
        case 'prepared': // prepare调用成功后上报该状态机
          console.info('AVPlayer state prepared called.');
          avPlayer.play(); // 调用播放接口开始播放
          break;
        case 'playing': // play成功调用后触发该状态机上报
          console.info('AVPlayer state playing called.');
          if (this.count !== 0) {
            if (this.isSeek) {
              console.info('AVPlayer start to seek.');
              avPlayer.seek(avPlayer.duration); //seek到音频末尾
            } else {
              // 当播放模式不支持seek操作时继续播放到结尾
              console.info('AVPlayer wait to play end.');
            }
          } else {
            avPlayer.pause(); // 调用暂停接口暂停播放
          }
          this.count++;
          break;
        case 'paused': // pause成功调用后触发该状态机上报
          console.info('AVPlayer state paused called.');
          avPlayer.play(); // 再次播放接口开始播放
          break;
        case 'completed': // 播放结束后触发该状态机上报
          console.info('AVPlayer state completed called.');
          avPlayer.stop(); //调用播放结束接口
          break;
        case 'stopped': // stop接口成功调用后触发该状态机上报
          console.info('AVPlayer state stopped called.');
          avPlayer.reset(); // 调用reset接口初始化avplayer状态
          break;
        case 'released':
          console.info('AVPlayer state released called.');
          break;
        case 'error':
          console.info('AVPlayer state error called.');
          break;
        default:
          console.info('AVPlayer state unknown called.');
          break;
      }
    })
  }

  async playLocalSound() {
    hilog.info(0x0000, '音频', '%{public}s', 'playLocalSound');
    console.debug(`[音频]playLocalSound`)
    try {
      // 创建avPlayer实例对象
      let avPlayer: media.AVPlayer = await media.createAVPlayer();
      this.log(`createAVPlayer success`)
      // 创建状态机变化回调函数
      this.setAVPlayerCallback(avPlayer);
      // 通过UIAbilityContext的resourceManager成员的getRawFd接口获取媒体资源播放地址
      // 返回类型为{fd,offset,length},fd为HAP包fd地址,offset为媒体资源偏移量,length为播放长度
      let context = getContext(this) as common.ExtensionContext;
      this.log(`getContext:context=${context}`)
      // hilog.info(0x0000, '组件', '%{public}s', `playLocalSound:context扩展名=${context.extensionAbilityInfo.name}}`);
      let fileDescriptor = await context.resourceManager.getRawFd('dang.mp3');
      this.log(`playLocalSound:fileDescriptor.length=${fileDescriptor.length}}`)
      let avFileDescriptor: media.AVFileDescriptor =
        { fd: fileDescriptor.fd, offset: fileDescriptor.offset, length: fileDescriptor.length };
      this.isSeek = true; // 支持seek操作
      // 为fdSrc赋值触发initialized状态机上报
      avPlayer.fdSrc = avFileDescriptor;
    } catch (e) {
      this.log(`playLocalSound出错:${e.toString()}`)
    }
  }

  // 以下demo为通过url设置网络地址来实现播放直播码流的demo
  async playOnlineSound() {
    this.log(`playOnlineSound`)
    // 创建avPlayer实例对象
    let avPlayer: media.AVPlayer = await media.createAVPlayer();
    this.log(`createAVPlayer success`)
    // 创建状态机变化回调函数
    this.setAVPlayerCallback(avPlayer);
    avPlayer.url = 'https://clemmensen.top/static/muyu.mp3';
  }
}

音频播放代码提供了本地和在线两个播放逻辑。

结论

期望:
在应用未运行的情况下,用卡片call方法拉起App到后台并播放音频。

实测:
播放rawfile音频失败
播放在线音频成功

向官方技术支持咨询:

鸿蒙技术支持
开发者你好,这边测试本地是有音频的敲击声啊

159******50
要先杀掉应用再点;正常场景是“应用未运行的情况下”点击卡片播放音频

鸿蒙技术支持
确实是的,正在内部分析中

鸿蒙技术支持
开发者你好,初步结论是 let context = getContext(this) as common.ExtensionContext;
此场景中上述context获取不到,导致不能读取rawfile文件

159******50
这个我清楚,日志就能看到的。问题是怎么解决呢?有没有其他方案读取rawfile?

鸿蒙技术支持
当前卡片框架不支持获取context,这个确认为当前规格

159******50let context = getContext(this) as common.ExtensionContext;”这个已经是通过call事件拉起application,走到application的代码里了,不算卡片代码的运行环境吧?

鸿蒙技术支持
开发者你好,通过call事件去拉起还是借助卡片的能力,卡片里面本身就是受限功能

总结与体会

😅😅😅在鸿蒙系统初期开发这类小众功能真的是遍地是坑。
普通的应用界面因为有大量应用在开发,所以坑填的相对快(但也不少),像元服务、卡片、音频,这样的混合领域,坑得数量可能超出你预期,开发者在前期做开发计划时尽量保守一些,不要脑袋一热,用iOS/Android的开发经验来轻率定时间进度。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐