(已弃用) 使用 Oboe 构建音乐游戏

1. 开始之前

在此 Codelab 中,您将使用 Oboe 库 构建一个简单的音乐游戏。Oboe 是一个 C++ 库,使用 Android NDK 中的高性能音频 API。游戏目标是通过轻触屏幕来模仿您听到的拍手模式。

前提条件

  • C++ 基础知识,包括如何使用头文件和实现文件。

您将执行的操作

  • 使用 Oboe 库播放声音。
  • 创建低延迟音频流。
  • 混合声音。
  • 在时间轴上精确触发声音。
  • 将音频与屏幕 UI 同步。

您将需要的内容

2. 如何玩游戏?

游戏会播放一段持续循环的、带感的四拍伴奏。游戏开始时,它还会在小节的前三拍播放拍手声音。

用户必须在第二个小节开始时通过轻触屏幕,尝试以相同的时机重复这三次拍手。

每次用户轻触时,游戏都会播放拍手声音。如果轻触发生在正确的时间,屏幕会闪烁绿色。如果轻触太早或太晚,屏幕会分别闪烁橙色或紫色。

3. 开始

克隆项目

在 GitHub 上克隆 Oboe 仓库并切换到 game-codelab 分支。

git clone https://github.com/google/oboe 
cd oboe
git checkout game-codelab

在 Android Studio 中打开项目

加载 Android Studio 并打开 Codelab 项目

  1. 文件 > 打开...
  2. 选择 oboe/samples 文件夹

运行项目

  1. 选择 RhythmGame 运行配置。

7b4f35798850bf56.png

  1. 按下 Control+R 构建并运行模板应用。它应该能够编译和运行,但除了将屏幕变为黄色外,没有任何其他功能。您将在本 Codelab 中为游戏添加功能。

b765df05ad65059a.png

打开 RhythmGame 模块

您将在此 Codelab 中处理的文件存储在 RhythmGame 模块中。在项目窗口中展开此模块,确保选择了 Android 视图。

现在展开 cpp/native-lib 文件夹。在此 Codelab 期间,您将编辑 Game.hGame.cpp

3852ca925b510220.png

与最终版本进行比较

在 Codelab 期间,参考存储在 master 分支中的代码最终版本会很有用。Android Studio 使跨分支比较文件更改变得容易。

  1. 在项目视图中右键单击文件。
  2. 转到 git > 与分支比较... > master

564bed20e0c63be.png

这将打开一个新窗口,突出显示差异。

4. 架构概览

游戏架构如下

fb908048f894be35.png

UI

图表的左侧显示了与 UI 相关的对象。

每次屏幕需要更新时(通常每秒 60 次),OpenGL Surface 都会调用 tick。然后,Game 会指示任何 UI 渲染对象将像素渲染到 OpenGL Surface,屏幕就会更新。

游戏的 UI 非常简单:单个方法 SetGLScreenColor 更新屏幕颜色。以下颜色用于显示游戏中发生的情况

  • 黄色——游戏正在加载。
  • 红色——游戏加载失败。
  • 灰色——游戏正在运行。
  • 橙色——用户轻触太早。
  • 绿色——用户轻触及时。
  • 紫色——用户轻触太晚。

轻触事件

每次用户轻触屏幕时,都会调用 tap 方法,并传入事件发生的时间。

音频

图表的右侧显示了与音频相关的对象。Oboe 提供 AudioStream 类和相关对象,允许 Game 将音频数据发送到音频输出设备(扬声器或耳机)。

每次 AudioStream 需要更多数据时,它都会调用 AudioStreamDataCallback::onAudioReady。这会将一个名为 audioData 的数组传递给 GameGame 必须用 numFrames 个音频帧填充该数组。

5. 播放声音

从发出声音开始!您将加载一个 MP3 文件到内存中,并在用户轻触屏幕时播放它。

构建 AudioStream

一个 AudioStream 允许您与音频设备(如扬声器或耳机)通信。要创建一个,您需要使用 AudioStreamBuilder。这允许您指定在打开流后希望流具有的属性。

Game 类中创建一个新的私有方法 openStream。首先,将方法声明添加到 Game.h 中。

private:
   ...
   bool openStream();

然后将实现添加到 Game.cpp 中。

bool Game::openStream() {
   AudioStreamBuilder builder;
   builder.setFormat(AudioFormat::Float);
   builder.setFormatConversionAllowed(true);
   builder.setPerformanceMode(PerformanceMode::LowLatency);
   builder.setSharingMode(SharingMode::Exclusive);
   builder.setSampleRate(48000);
   builder.setSampleRateConversionQuality(
      SampleRateConversionQuality::Medium);
   builder.setChannelCount(2);
}

这里有很多内容,所以让我们分解一下。

您创建了流构建器并请求了以下属性

  • setFormat 请求样本格式为 float。
  • setFormatConversionAllowed 允许 Oboe 始终提供具有 float 样本的流,无论底层流格式如何。这对于与仅支持 16 位样本流的 Lollipop 之前设备兼容非常重要。
  • setPerformanceMode 请求低延迟流。您希望最大程度地减少用户轻触屏幕和听到拍手声音之间的延迟。
  • setSharingMode 请求对音频设备的独占访问。这在支持独占访问的音频设备上进一步减少延迟。
  • setSampleRate 将流的采样率设置为每秒 48000 个样本。这与您的源 MP3 文件的采样率相匹配。
  • setSampleRateConversionQuality 设置底层音频设备不支持 48000 采样率时使用的重采样算法的质量。在这种情况下,使用了中等质量的算法。这在重采样质量和计算负载之间提供了良好的权衡。
  • setChannelCount 将流的通道数设置为 2,即立体声流。同样,这与您的 MP3 文件的通道数相匹配。

打开流

现在流已经使用构建器设置好了,您可以继续打开它。为此,使用 openStream 方法,该方法将 std::shared_ptr<AudioStream> 作为其参数。

  1. Game.h 内部声明一个类型为 std::shared_ptr<AudioStream> 的成员变量。
private:
    ...
    std::shared_ptr<AudioStream> mAudioStream;
}
  1. 将以下代码添加到 Game.cppopenStream 的末尾。
bool Game::openStream() {

    [...]
    Result result = builder.openStream(mAudioStream);
    if (result != Result::OK){
        LOGE("Failed to open stream. Error: %s", convertToText(result));
        return false;
    }
    return true;
}

此代码尝试打开流,如果发生错误则返回 false

加载声音文件

项目在 assets 文件夹中包含一个名为 CLAP.mp3 的文件,其中包含 MP3 音频数据。您将解码该 MP3 文件并将其作为音频数据存储在内存中。

  1. 打开 Game.h,并声明一个名为 mClapstd::unique_ptr<Player> 和一个名为 bool setupAudioSources 的方法。
private:
    // ...existing code... 
    std::unique_ptr<Player> mClap;
    bool setupAudioSources();
  1. 打开 Game.cpp 并添加以下代码
bool Game::setupAudioSources() {

   // Set the properties of our audio source(s) to match those of our audio stream.
   AudioProperties targetProperties {
            .channelCount = 2,
            .sampleRate = 48000
   };
   
   // Create a data source and player for the clap sound.
   std::shared_ptr<AAssetDataSource> mClapSource {
           AAssetDataSource::newFromCompressedAsset(mAssetManager, "CLAP.mp3", targetProperties)
   };
   if (mClapSource == nullptr){
       LOGE("Could not load source data for clap sound");
       return false;
   }
   mClap = std::make_unique<Player>(mClapSource);
   return true;
}

这将 CLAP.mp3 解码为与我们的音频流具有相同通道数和采样率的 PCM 数据,然后将其存储在 Player 对象中。

设置回调

到目前为止,一切顺利!您已经有了打开音频流和将 MP3 文件加载到内存的方法。现在,您需要将音频数据从内存传输到音频流中。

为此,您可以使用 AudioStreamDataCallback,因为这种方法提供了最佳性能。更新您的 Game 类以实现 AudioStreamDataCallback 接口。

  1. 打开 Game.h 并找到以下行
class Game {
  1. 将其更改为以下内容
class Game : public AudioStreamDataCallback {
  1. 覆盖 AudioStreamDataCallback::onAudioReady 方法
public:
    // ...existing code... 
     
    // Inherited from oboe::AudioStreamDataCallback
    DataCallbackResult
    onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) override;

onAudioReadyaudioData 参数是一个数组,您可以使用 mClap->renderAudio 将音频数据渲染到其中。

  1. onAudioReady 的实现添加到 Game.cpp
// ...existing code... 

DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {
    mClap->renderAudio(static_cast<float *>(audioData), numFrames);
    return DataCallbackResult::Continue;
}

返回值 DataCallbackResult::Continue 告诉流您打算继续发送音频数据,因此回调应继续。如果您返回 DataCallbackResult::Stop,回调将停止,流将不再播放音频。

要完成数据回调设置,您必须在 openStream 中使用 setDataCallback 告知音频流构建器数据回调对象的位置。请在打开流之前执行此操作。

bool Game::openStream() {
    ...
    builder.setDataCallback(this);
    Result result = builder.openStream(mAudioStream);

加载

游戏开始之前,必须发生几件事

  • 必须使用 openStream 打开音频流。
  • 游戏使用的任何 MP3 文件需要使用 setupAudioSources 进行解码并加载到内存中。

这些操作是阻塞的,取决于 MP3 文件的大小和解码器的速度,可能需要几秒钟才能完成。您应避免在主线程上执行这些操作。否则,您可能会遇到可怕的 ANR

游戏开始之前必须发生的另一件事是启动音频流。在其他加载操作完成后执行此操作是合理的。

将以下代码添加到现有的 load 方法中。您将在单独的线程中调用此方法。

void Game::load() {

   if (!openStream()) {
       mGameState = GameState::FailedToLoad;
       return;
   }

   if (!setupAudioSources()) {
       mGameState = GameState::FailedToLoad;
       return;
   }

   Result result = mAudioStream->requestStart();
   if (result != Result::OK){
       LOGE("Failed to start stream. Error: %s", convertToText(result));
       mGameState = GameState::FailedToLoad;
       return;
   }

   mGameState = GameState::Playing;
}

情况如下。您正在使用成员变量 mGameState 来跟踪游戏状态。最初是 Loading,然后变为 FailedToLoadPlaying。您稍后会更新 tick 方法来检查 mGameState 并相应地更新屏幕背景颜色。

您调用 openStream 打开您的音频流,然后调用 setupAudioSources 从内存中加载 MP3 文件。

最后,您通过调用 requestStart 启动音频流。这是一个非阻塞方法,它会尽快启动音频回调。

异步启动

现在您需要做的就是异步调用您的加载方法。为此,您可以使用C++ async 函数,该函数会在单独的线程上异步调用一个函数。更新您的游戏的 start 方法

void Game::start() {
   mLoadingResult = std::async(&Game::load, this);
}

这只是异步调用您的 load 方法并将结果存储在 mLoadingResult 中。

更新背景颜色

这一步很简单。根据游戏状态,您可以更新背景颜色。

更新 tick 方法

void Game::tick(){
   switch (mGameState){
       case GameState::Playing:
           SetGLScreenColor(kPlayingColor);
           break;
       case GameState::Loading:
           SetGLScreenColor(kLoadingColor);
           break;
       case GameState::FailedToLoad:
           SetGLScreenColor(kLoadingFailedColor);
           break;
   }
}

处理轻触事件

您快完成了,只剩下最后一件事。每次用户轻触屏幕时,都会调用 tap 方法。通过调用 setPlaying 启动拍手声音。

  1. 将以下代码添加到 tap
void Game::tap(int64_t eventTimeAsUptime) {
    if (mClap != nullptr){ 
        mClap->setPlaying(true);
    }
}
  1. 构建并运行应用。当您轻触屏幕时,应该会听到拍手的声音。
  2. 给自己鼓掌!

6. 播放多种声音

只播放一个拍手声音很快就会变得无聊。最好也能播放一段带有节拍的伴奏,以便您可以跟着拍手。

到目前为止,游戏只将拍手声音放入音频流中。

使用混音器

要同时播放多种声音,必须将它们混合在一起。方便的是,此 Codelab 提供了一个 Mixer 对象来完成此操作。

b63f396874540947.png

创建伴奏和混音器

  1. 打开 Game.h 并为伴奏和混音器声明另一个 std::unique_ptr<Player>
private:
    ..
    std::unique_ptr<Player> mBackingTrack; 
    Mixer mMixer;
  1. Game.cpp 中,在 setupAudioSources 中加载拍手声音后添加以下代码。
bool Game::setupAudioSources() {
   ...
   // Create a data source and player for your backing track.
   std::shared_ptr<AAssetDataSource> backingTrackSource {
           AAssetDataSource::newFromCompressedAsset(mAssetManager, "FUNKY_HOUSE.mp3", targetProperties)
   };
   if (backingTrackSource == nullptr){
       LOGE("Could not load source data for backing track");
       return false;
   }
   mBackingTrack = std::make_unique<Player>(backingTrackSource);
   mBackingTrack->setPlaying(true);
   mBackingTrack->setLooping(true);

   // Add both players to a mixer.
   mMixer.addTrack(mClap.get());
   mMixer.addTrack(mBackingTrack.get());
   mMixer.setChannelCount(mAudioStream->getChannelCount());
   return true;
}

这将 FUNKY_HOUSE.mp3 资源(其中包含与拍手声音资源相同格式的 MP3 数据)的内容加载到 Player 对象中。播放从游戏开始时开始并无限循环。

拍手声音和伴奏都被添加到混音器中,并且混音器的通道数被设置为与您的音频流的通道数相匹配。

更新音频回调

您现在需要告诉音频回调使用混音器而不是拍手声音进行渲染。

  1. onAudioReady 更新为以下内容
DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {

    mMixer.renderAudio(static_cast<float*>(audioData), numFrames);
    return DataCallbackResult::Continue;
}
  1. 构建并运行游戏。当您轻触屏幕时,应该会听到伴奏和拍手声音。可以随意即兴演奏一会儿!

7. 将拍手声音排队

现在事情开始变得有趣了。您将开始添加游戏机制。游戏会在特定时间播放一系列拍手声音。这称为拍手模式

对于这个简单的游戏,拍手模式只是在伴奏的第一个小节的第一拍开始的三个拍手。用户必须在第二个小节的第一拍开始重复相同的模式。

游戏何时应该播放拍手声?

伴奏的速度是每分钟 120 拍,即每 0.5 秒一拍。因此,游戏必须在伴奏中的以下时间播放拍手声音

节拍

时间(毫秒)

1

0

2

500

3

1,000

将拍手事件与伴奏同步

每次调用 onAudioReady 时,伴奏(通过混音器)的音频帧会被渲染到音频流中——这就是用户实际听到的声音。通过计算已写入的帧数,您就知道了确切的播放时间,因此也就知道何时播放拍手声。

考虑到这一点,以下是您如何在恰好正确的时间播放拍手事件的方法

  1. 使用 convertFramesToMillis 将当前音频帧转换为歌曲位置(毫秒)。
  2. 使用该时间检查是否到了拍手事件的时间。如果需要,则播放。

通过计算在 onAudioReady 内部写入的帧数,您就知道了确切的播放位置,并且可以确保与伴奏完美同步。

跨线程通信

游戏有三个线程:一个 OpenGL 线程、一个 UI 线程(主线程)和一个实时音频线程。

9cd3945342b3a7d9.png

拍手事件从 UI 线程推送到调度队列,并从音频线程弹出队列。

队列是从多个线程访问的,因此它必须是线程安全的。它还必须是无锁的,这样它就不会阻塞音频线程。这个要求适用于与音频线程共享的任何对象。为什么?因为阻塞音频线程会导致音频毛刺,这是谁都不想听到的!

添加代码

游戏已经包含一个 LockFreeQueue 类模板,当与单个读取线程(在本例中为音频线程)和单个写入线程(UI 线程)一起使用时,它是线程安全的。

要声明 LockFreeQueue,必须提供两个模板参数

  • 每个元素的数据类型:使用 int64_t,因为它允许的最大时长(毫秒)超过您可能创建的任何音轨长度。
  • 队列容量(必须是 2 的幂):有 3 个拍手事件,因此将容量设置为 4。
  1. 打开 Game.h 并添加以下声明
private:
    // ...existing code...  
    Mixer mMixer;
    
    LockFreeQueue<int64_t, 4> mClapEvents;
    std::atomic<int64_t> mCurrentFrame { 0 };
    std::atomic<int64_t> mSongPositionMs { 0 };
    void scheduleSongEvents();
  1. Game.cpp 中,创建一个名为 scheduleSongEvents 的新方法。
  2. 在此方法中,将拍手事件入队。
void Game::scheduleSongEvents() {

   // Schedule the claps.
   mClapEvents.push(0);
   mClapEvents.push(500);
   mClapEvents.push(1000);
}
  1. 在流启动之前,从您的 load 方法中调用 scheduleSongEvents,以便在伴奏开始播放之前将所有事件入队。
void Game::load() {
   ...
   scheduleSongEvents();
   Result result = mAudioStream->requestStart();
   ...
}
  1. onAudioReady 更新为以下内容
DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {

   auto *outputBuffer = static_cast<float *>(audioData);
   int64_t nextClapEventMs;

   for (int i = 0; i < numFrames; ++i) {

       mSongPositionMs = convertFramesToMillis(
               mCurrentFrame,
               mAudioStream->getSampleRate());

       if (mClapEvents.peek(nextClapEventMs) && mSongPositionMs >= nextClapEventMs){
           mClap->setPlaying(true);
           mClapEvents.pop(nextClapEventMs);
       }
            mMixer.renderAudio(outputBuffer+(oboeStream->getChannelCount()*i), 1);
       mCurrentFrame++;
   }

   return DataCallbackResult::Continue;
}

for 循环对 numFrames 进行迭代。在每次迭代中,它执行以下操作

  • 使用 convertFramesToMillis 将当前帧转换为毫秒时间
  • 使用该时间检查是否到了拍手事件的时间。如果需要,则发生以下情况
  • 事件从队列中弹出。
  • 拍手声音被设置为播放状态。
  • mMixer 渲染单个音频帧(使用指针算术告诉 mMixeraudioData 的何处渲染帧)。
  1. 构建并运行游戏。游戏开始时,应该正好按照节拍播放三次拍手声。轻触屏幕仍然会播放拍手声音。

您可以随意通过更改拍手事件的帧值来尝试不同的拍手模式。您还可以添加更多拍手事件。请记住增加 mClapEvents 队列的容量。

8. 添加得分和视觉反馈

游戏播放一个拍手模式,并期望用户模仿该模式。最后,通过对用户的轻触进行评分来完成游戏。

用户轻触的时间对吗?

游戏在第一个小节拍手三次后,用户应该从第二个小节的第一拍开始轻触三次。

您不应该期望用户在恰好正确的时间轻触——那几乎是不可能的!相反,在预期时间之前和之后允许一定的容差。这定义了一个时间范围,您称之为轻触窗口

如果用户在轻触窗口内轻触,则屏幕闪烁绿色;太早则闪烁橙色;太晚则闪烁紫色。

35fbf0fb442b5eb7.png

存储轻触窗口很容易。

  1. 将每个窗口中心的歌曲位置存储在队列中,就像您对拍手事件所做的那样。然后,当用户轻触屏幕时,您可以从队列中弹出每个窗口。

轻触窗口中心的歌曲位置如下

节拍

时间(毫秒)

5

2,000

6

2,500

7

3,000

  1. 声明一个新的成员变量来存储这些拍手窗口。
private: 
    LockFreeQueue<int64_t, kMaxQueueItems> mClapWindows;
  1. 添加拍手窗口的歌曲位置。
void Game::scheduleSongEvents() {

    ...
    // Schedule the clap windows.
    mClapWindows.push(2000);
    mClapWindows.push(2500);
    mClapWindows.push(3000);
}

比较轻触事件与轻触窗口

当用户轻触屏幕时,您需要知道轻触是否落在当前的轻触窗口内。轻触事件作为系统运行时间(自启动以来的毫秒数)传递,因此您需要将其转换为歌曲中的位置。

幸运的是,这很简单。每次调用 onAudioReady 时,存储当前歌曲位置的运行时间。

  1. 在头文件中声明一个成员变量来存储歌曲位置
private: 
    int64_t mLastUpdateTime { 0 };
  1. 将以下代码添加到 onAudioReady 的末尾
DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {
   ...
   mLastUpdateTime = nowUptimeMillis();

   return DataCallbackResult::Continue;
}
  1. 将 tap 方法更新为以下内容
void Game::tap(int64_t eventTimeAsUptime) {

   if (mGameState != GameState::Playing){
       LOGW("Game not in playing state, ignoring tap event");
   } else {
       mClap->setPlaying(true);

       int64_t nextClapWindowTimeMs;
       if (mClapWindows.pop(nextClapWindowTimeMs)){

           // Convert the tap time to a song position.
           int64_t tapTimeInSongMs = mSongPositionMs + (eventTimeAsUptime - mLastUpdateTime);
           TapResult result = getTapResult(tapTimeInSongMs, nextClapWindowTimeMs);
           mUiEvents.push(result);
       }
   }
}

您使用提供的 getTapResult 方法确定用户轻触的结果。然后,您将结果推送到 UI 事件队列中。这在下一节中解释。

更新屏幕

一旦您知道用户轻触的准确性(早、晚、完美),您就需要更新屏幕以提供视觉反馈。

为此,您使用 LockFreeQueue 类的另一个实例,并使用 TapResult 对象创建一个 UI 事件队列。

  1. 声明一个新的成员变量来存储这些。
private: 
    LockFreeQueue<TapResult, kMaxQueueItems> mUiEvents;
  1. tick 方法中,弹出任何待处理的 UI 事件并相应地更新屏幕颜色。
  2. 更新 tickPlaying 状态的代码。
case GameState::Playing:
   TapResult r;
   if (mUiEvents.pop(r)) {
       renderEvent(r);
   } else {
       SetGLScreenColor(kPlayingColor);
   }
   break;
  1. 构建并运行游戏。

游戏开始时,您应该会听到三次拍手声。如果您在第二个小节准确地按节拍轻触三次,每次轻触时屏幕都应该闪烁绿色。如果太早,它会闪烁橙色。如果太晚,它会闪烁紫色。祝您好运!

9. 停止和重新启动音乐

处理暂停和关闭事件

如果用户关闭或暂停应用,必须停止音频。将以下代码添加到 Game.cpp 中的 stop 方法中

void Game::stop(){
   if (mAudioStream){
       mAudioStream->stop();
       mAudioStream->close();
       mAudioStream.reset();
   }
}

这将停止音频流,这意味着数据回调将不再被调用,游戏将不再产生任何音频。close 释放与音频流关联的任何资源,而 reset 释放所有权,以便可以安全删除流。

只要调用游戏的 onPause 方法,就会调用 Game::stop 方法。

连接耳机

当用户连接外部音频设备(例如耳机)时,音频不会自动路由到该音频设备。相反,现有的音频流会断开连接。这是有意为之的,允许您根据新音频设备的属性调整内容。

例如,您可以将音频源重采样到音频设备的原生采样率,以避免重采样带来的 CPU 开销。

要监听音频流断开事件,您可以覆盖 AudioStreamErrorCallback::onErrorAfterClose。这将在音频流关闭后被调用。

  1. 打开 Game.h 并找到以下行
class Game : public AudioStreamDataCallback {
  1. 将其更改为以下内容
class Game : public AudioStreamDataCallback, AudioStreamErrorCallback {
  1. 覆盖 AudioStreamErrorCallback::onErrorAfterClose 方法
public:
    // ...existing code... 
     
    // Inherited from oboe::AudioStreamErrorCallback.
    void onErrorAfterClose(AudioStream *oboeStream, Result error) override;
  1. onErrorAfterClose 的实现添加到 Game.cpp
// ...existing code... 

void Game::onErrorAfterClose(AudioStream *audioStream, Result error) {
   if (result == Result::ErrorDisconnected){
       mGameState = GameState::Loading;
       mAudioStream.reset();
       mMixer.removeAllTracks();
       mCurrentFrame = 0;
       mSongPositionMs = 0;
       mLastUpdateTime = 0;
       start();
   } else {
       LOGE("Stream error: %s", convertToText(result));
   }
}

如果音频流断开连接,这将把游戏重置为初始状态并重新启动。

构建并运行游戏。如果将它切换到后台,音频应该停止。将其切换回前台应该会重新启动游戏。

现在尝试连接和断开耳机。游戏每次都应该重新启动。

在实际游戏中,当音频设备更改时,您可能希望保存和恢复游戏状态。

10. 恭喜

恭喜!您使用 Oboe 库构建了一个音乐游戏。

了解更多