На написание данной статьи меня мотивировала другая статья о пригодном для использования в маленьких проектах менеджере звуков. В данном посте я опишу некоторые недостатки, которые автор не перечислил, и предложу свой вариант реализации, на мой взгляд, исправляющий их.
Данная статья будет полезна как начинающим разработчикам для приобретения опыта и получение готовой наработки, так и заядлым архитекторам, в офисах которых не утихают споры о значимости отделения вида от модели и удаления статики из кода. Я уверен в том, что решение, предложенное мною, не является полностью универсальным, и имеет свои недостатки, однако важным и приятным элементом для меня стало бы то, что каждый заинтересованный читатель почерпнул бы полезное для себя и улучшил собственные модули, используя мои советы.
Проблемы
Злой одиночка
Многие могут не согласиться со мной, но я считаю, что использование синглтонов, тем более в таких аспектах, как воспроизведение звука, недопустимо в проектах любого масштаба. С помощью этого анти-паттерна наглухо связываются все участки кода, с прямым указанием типа, что связывает руки сразу по нескольким направлениям. Написать тест к синглтону если и можно, то очень тяжело, выглядит это некрасиво и детерминированностью не блещет. Так же вы не сможете достаточно элегантно написать тест для любого модуля, который будет использовать этот менеджер звуков. Из за того, что используется один и тот же экземпляр с неконтролируемым циклом жизни, вы так же свяжете руки сами себе, негласно создавая логическую зависимость в отдельных участках кода, которые вообще не должны знать друг о друге.
Примеры:
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, интерфейс предназначенный для общим управлением звуком (вклвыкл, общая громкость):
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д звуков, и дальнейшего их контроля.
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, воспроизведение музыки и контроль.
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 через дополнительные абстракции, а в тесте заменять абстракции на пустышки. К тому же так тест будет выполняться за минимум времени.
Автор: HexGrimm