Альтернативный Sound Manager для мелких и средних проектов на Unity3D

от автора

imageНа написание данной статьи меня мотивировала другая статья о пригодном для использования в маленьких проектах менеджере звуков. В данном посте я опишу некоторые недостатки, которые автор не перечислил, и предложу свой вариант реализации, на мой взгляд, исправляющий их.

Данная статья будет полезна как начинающим разработчикам для приобретения опыта и получение готовой наработки, так и заядлым архитекторам, в офисах которых не утихают споры о значимости отделения вида от модели и удаления статики из кода. Я уверен в том, что решение, предложенное мною, не является полностью универсальным, и имеет свои недостатки, однако важным и приятным элементом для меня стало бы то, что каждый заинтересованный хабраюзер почерпнул бы полезное для себя и улучшил собственные модули, используя мои советы.

Проблемы

Злой одиночка

Многие могут не согласиться со мной, но я считаю, что использование синглтонов, тем более в таких аспектах, как воспроизведение звука, недопустимо в проектах любого масштаба. С помощью этого анти-паттерна наглухо связываются все участки кода, с прямым указанием типа, что связывает руки сразу по нескольким направлениям. Написать тест к синглтону если и можно, то очень тяжело, выглядит это некрасиво и детерминированностью не блещет. Так же вы не сможете достаточно элегантно написать тест для любого модуля, который будет использовать этот менеджер звуков. Из за того, что используется один и тот же экземпляр с неконтролируемым циклом жизни, вы так же свяжете руки сами себе, негласно создавая логическую зависимость в отдельных участках кода, которые вообще не должны знать друг о друге.

Примеры:

static void PlayMusic(string name); static void PlaySound(string name, bool pausable = true); 

Метод настаивает на том, чтобы сторонний код знал о конкретных именах мелодий. На программиста возлагается обязанность в каждом из модулей, ответственных за свои звуки, корректно передавать аргументы. И таких мест в проекте может быть очень много: различные элементы UI, стреляющие/умирающие юниты, окружение. В комментариях к реф статье один из читателей предлагает использовать в аргументах различные каналы для звука, что так же логически связывает участки кода:

public void PlayFX (AudioClip clip, SoundFXChannel channel = SoundFXChannel.First, bool forceInterrupt = false) { } public void StopFX (SoundFXChannel channel) { } 

Теперь, например, кнопки (или если угодно UIManager) используя методы, должны учитывать, к какому из каналов они относятся, фактически это опять возлагается на программиста.

Слишком много доступа

Для меня всегда было странным то, что когда я в отдельном коде вызываю метод, мне возвращают тип-наследник от MonoBehaviour. Безопасно ли пускать короутины по нему? Защитил ли разработчик его от Destroy()? Или хочу ли я вообще видеть в дальнейшем в коде “using UnityEngine” или мне не нужен MonoBehaviour? Эта проблема частично относится и к предыдущему пункту о синглтоне, нам не нужна ссылка на сам экземпляр, нам достаточно API для работы с ним. Забавно, но даже если вы реализуете статический вызов таким образом:

private static SoundManager instance; public static ISoundManager Instance { get{ return (instance as ISoundManager) }} 

То при получении абстракции, вам все равно придется использовать конкретный тип:

ISoundManager sm = SoundManager.Instance; 

Что решает проблему лишь частично.

Вшитый путь и прямая загрузка

private AudioClip LoadClip(string name)     {         string path = "Sounds/" + name;         AudioClip clip = Resources.Load<AudioClip>(path);         return clip;     } 

Отложенная загрузка звуков, на мой взгляд, далеко не всегда имеет смысл. Во-первых, в настройках импорта звуков в юнити можно настроить то, как хранить звук: сразу в оперативной памяти, стримить с диска или загружать в память, но преобразовывать непосредственно перед воспроизведением. Подробнее о настройках импорта. Во-вторых, опыт разбора логов сборки юнити подсказывает, что ресурсы звуков по общему размеру в среднем стоят на 3ем или ниже месте. И оптимизацию памяти, если и начинать, то не со звуков однозначно. (Конечно, это потенциально не применимо к проектам, игровой процесс которых завязан на звуках). Подробнее о логах.

Теперь по поводу вшитого в код пути: Опять на программиста возлагается ответственность- следить за соответствием пути при переносе этого модуля из проекта в проект. Настоящие пляски начинаются, когда приходит в команду здравая мысль: “Почему бы не сделать git субмодуль, положить туда аудио менеджер, чтобы во всех проектах, если необходимо, была бы последняя версия этого модуля?”. Поскольку путь вшит в код, мы не можем его менять, так как на остальных проектах он станет ошибочным. С другой стороны, если менять путь только локально, то гит всегда будет светить вам это изменение.

Собственное решение

Код модуля находится по адресу:https://github.com/hexgrimm/Audio
Для публикации в рамках статьи код был упрощен, я убрал большую часть тестов и абстракций для них, для того, чтобы код смотрелся понятнее. В проектах под моим руководством используется модуль с несколько большим потенциалом расширяемости и объемной конфигурацией.

Итак, для начала поговорим об архитектуре:

Данный модуль аудио считается конечным листом в графе зависимостей любой архитектуры, он не требует зависимостей ниже по графу, и ему не важно, кто его создает, но имеется ограничение: Этот модуль должен иметь стиль жизни “Singleton” (не путать с паттерном проектирования Singleton, подробнее в книге “Внедрение зависимостей в .NET” Автор: Марк Симан). Это связанно с требованием Unity3D на только один AudioListener в приложении. В случае, если вы используете внедрение зависимостей в проекте, то бинды будут выглядеть следующим образом (на примере Ninject):

binder.Bind<IAudioController, IAudioPlayer, IMusicPlayer>().To<AudioController>().InSingletonScope(); 

В случае, если вы хотите просто создать этот класс и использовать его в проекте, убедитесь, что всем источникам вызова воспроизведения звука предоставляются абстракции одного и тот же экземпляра.

Как пример:

var ac = new AudioController(); IAudioController iac = ac; IAudioPlayer iap = ac; IMusicPlayer imp = ac; 

И в дальнейшем работа и поставка всем источникам ведется только с абстракциями iac, iap, imp.

Абстракции

IAudioController, интерфейс предназначенный для общим управлением звуком (вкл\выкл, общая громкость):

IAudioController

public interface IAudioController : IDisposable     {         /// <summary>         /// Enabled or disables all sounds in game. All music sources sets volume to = 0 and stops their playback;         /// </summary>         bool SoundEnabled { get; set; }         /// <summary>         /// Enables or disables all musics in game. All music sources sets volume to = 0 or MusicVolume value;         /// </summary>         bool MusicEnabled { get; set; }         /// <summary>         /// Sound volume range 1 - 0         /// </summary>         float SoundVolume { get; set; }         /// <summary>         /// Music volume in range 1 - 0         /// </summary>         float MusicVolume { get; set; }     } 

IAudioPlayer, интерфейс предназначен для воспроизведения 2д и 3д звуков, и дальнейшего их контроля.

IAudioPlayer

public interface IAudioPlayer     {         /// <summary>         /// plays audio clip if sound enabled.         /// </summary>         /// <param name="clip">Audio clip to play.</param>         /// <param name="volumeProportion">volume in range 1 - 0, when plays its also affected by global volume setting.</param>         /// <param name="looped">should clip play be looped</param>         /// <returns> returns code for this sound call to control playback for concrete clip played.</returns>         int PlayAudioClip2D(AudioClip clip, float volumeProportion = 1f, bool looped = false);          /// <summary>         /// Plays audio clip in concrete 3d position         /// </summary>         /// <param name="clip">Audio clip to play</param>         /// <param name="position">world position of audio source.</param>         /// <param name="maxSoundDistance">parameter seted to audioSource.MaxDistance</param>         /// <param name="volumeProportion">volume in range 1 - 0, when plays its also affected by global volume setting.</param>         /// <param name="looped">should clip play be looped</param>         /// <returns></returns>         int PlayAudioClip3D(AudioClip clip, Vector3 position, float maxSoundDistance, float volumeProportion = 1f, bool looped = false);         /// <summary>         /// stop playing concrete clip.         /// </summary>         /// <param name="audioCode">code, recived from methods PlayAudioClip2D or PlayAudioClip3D</param>         void StopPlayingClip(int audioCode);         /// <summary>         /// Returns true if audio code contains in player and can be controlled.         /// </summary>         /// <param name="audioCode">audio code</param>         /// <returns></returns>         bool IsAudioClipCodePlaying(int audioCode);         /// <summary>         /// Sets global audio listener to concrete position         /// </summary>         /// <param name="position">v3 in world coordinates</param>         void SetAudioListenerToPosition(Vector3 position);         /// <summary>         /// Set position of source if source exist.         /// </summary>         /// <param name="audioCode">code of source</param>         /// <param name="destinationPos">target position in world coordinates</param>         void SetSourcePositionTo(int audioCode, Vector3 destinationPos);     } 

IMusicPlayer, воспроизведение музыки и контроль.

IMusicPlayer

public interface IMusicPlayer     {         /// <summary>         /// plays music clip as 2d sound with concrete volume padding.         /// </summary>         /// <param name="clip">music clip</param>         /// <param name="volumeProportion">volume proportions of sound in range of 1 - 0. Its also affected by global music volume settings</param>         /// <returns>concrete music playback code for future control</returns>         int PlayMusicClip(AudioClip clip, float volumeProportion = 1f);         /// <summary>         /// stops playing music clip and clear data for this code.         /// </summary>         /// <param name="audioCode">audio code to find audio clip playback</param>         void StopPlayingMusicClip(int audioCode);         /// <summary>         /// Pauses concrete music clip play, it could be resumed.         /// </summary>         /// <param name="audioCode"></param>         void PausePlayingClip(int audioCode);         /// <summary>         /// Resumes concrete music clip play if it was paused before.         /// </summary>         /// <param name="audioCode"></param>         void ResumeClipIfInPause(int audioCode);         /// <summary>         /// Returns true if audio code contains in player and can be controlled.         /// </summary>         /// <param name="audioCode">audio code</param>         /// <returns></returns>         bool IsMusicClipCodePlaying(int audioCode);     } 

При вызове метода воспроизведения звука или музыки, потребителю выдается числовой код, по которому он в дальнейшем может контролировать звук.

Например, выключить его или сменить позицию источника звука, если объект движется.

Отдельным методом стоит:

SetAudioListenerToPosition(Vector3 position); 

В случае 3d звука и движущегося слушателя необходимо предоставить доступ к контролю его позиции.

Вы могли заметить, что одним из аргументов вызова воспроизведения является тип AudioClip, по моему мнению, логика хранения или ассоциации клипов и источников звука не должна находиться в самом контроллере, поэтому я просто вынес эти полномочия за модуль, тем самым позволяя потребителю модуля решать, создавать ли базу хранения звуков или ассоциировать клипы непосредственно с источниками (в большинстве наших случаев так и происходит. Различные юниты имеют женские и мужские голоса, эта информация — неотъемлемая часть юнитов, какого бы рода инкапсуляция бы не применялась; и именно юнит поставляет эту информацию, используя интерфейс IAudioPlayer).

Так же вы могли заметить, что IAudioController наследуется от IDisposable. Это сделано намеренно и обосновано ограничениями, которые накладывает Unity3D. В методе Dispose удаляются объекты юнити, созданные для обеспечения работоспособности модуля, на мой взгляд, относительно модуля объекты сцены являются “отдельно-управляемыми” ресурсами, и поскольку AudioController это не MonoBehaviour, мы не можем вызвать Destroy(). А сборщик мусора не сможет очистить ссылки, так как управляемые юнити ссылки будут живы. Вызывая метод Dispose, мы гарантируем, что все ресурсы и ссылки, связанные с юнити, были очищены. Хотя в маленьких проектах жизненный цикл аудио модуля по длине всегда схож с циклом работы приложения, так что возможно вам не стоит заморачиваться.

Так же прошу прощения за большое количество строк вида:

source.pitch = 1 + Random.Range(-0.1f, 0.1f); 

Использование магических чисел, конечно, недопустимо, и для примера написаны намеренно, так как конфигурация, которую в реальных проектах мы передаем через конструктор, усложняет код, а мне хотелось бы оставить код максимально простым для новичков.

Отдельно скажу пару слов про класс SavableValue<>. Служебный класс для хранения любых сериализуемых типов в Prefs пришлось продублировать в этом модуле, чтобы не тянуть отдельный namespace Utils. Мне не известно, как хорошо работает BinaryFormatter на отличных от мобильных платформах.

Что получилось в итоге

Не используя Singleton в проекте, мы создаем удобный шов, и в дальнейшем можем подменять абстракции, если необходимо. Теперь можно написать любой тест на воспроизведение классом звука всего лишь используя мок абстракции.

IAudioPlayer mock = Substitute.For<IAudioPlayer >(); var testClass = new Class(mock); 

Доступ к классам ограничен интерфейсами, ничего лишнего с ними сделать не получится (если не учитывать абуз с неверными audioCode). Никаких лишних зависимостей, кроме namespace HexGrimmDev.Audio не тянется. Как и в рекомендациях Марка Симона, вся лишняя ответственность вынесена за класс и по необходимости может передаваться через конструктор. Нет никаких внешних логических связей, можно распространять модуль как git-submodule.

Я понимаю, что не все изоляции одинаково полезные, но в данном случае для создания шва лишнего времени много не потребовалось. Для большего воодушевления предлагаю ознакомиться с лекцией Олега Чумакова на тему “Почему ваш Unity проект должен работать в консоли?”.

И так же настоятельно рекомендую передавать ссылки по модулям через конструктор, это конечно понятнее для потребителя, и к тому же это чертовски дисциплинирует. И самое главное, предлагаю не гоняться за полной универсализацией. Есть отличная лекция на эту тему "Как не увлечься погоней за универсализацией компонент".

Функциональный перечень в примере кода:

  • Воспроизведение и контроль 2d и 3d звуков а так же музыки.
  • Балансировка звука. (передается float аргумент с 0-1 диапазоном для точной балансировки отдельных звуков) (учитывается при изменении громкости)
  • Возможность зацикливания.
  • Изменение позиции слушателя для 3d звуков.
  • Есть случайный сдвиг pitch +-0.1f для всех звуков кроме музыки. (для примера)
  • Пауза и возобновление для музыки.

Из конкретных особенностей:

  • AudioMixer не используется.
  • В коде много магических чисел, подлежит рефакторингу перед использованием.
  • Нет плавного перехода между музыкальными клипами, можно реализовать множеством способов.
  • Из-за урезания кода и после удаления тестов есть вероятность что что-то работает не корректно, код является в первую очередь примером, а не средством.
  • Для написания тестов рекомендуется ввести шов между компонентами юнити и AudioController, и работать с AudioSource и AudioListener через дополнительные абстракции, а в тесте заменять абстракции на пустышки. К тому же так тест будет выполняться за минимум времени.

ссылка на оригинал статьи http://habrahabr.ru/post/275017/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *