![image](https://habrastorage.org/files/8b7/7c2/c73/8b77c2c7318b479ea35293deec0a2372.jpg)
Данная статья будет полезна как начинающим разработчикам для приобретения опыта и получение готовой наработки, так и заядлым архитекторам, в офисах которых не утихают споры о значимости отделения вида от модели и удаления статики из кода. Я уверен в том, что решение, предложенное мною, не является полностью универсальным, и имеет свои недостатки, однако важным и приятным элементом для меня стало бы то, что каждый заинтересованный хабраюзер почерпнул бы полезное для себя и улучшил собственные модули, используя мои советы.
Проблемы
Злой одиночка
Многие могут не согласиться со мной, но я считаю, что использование синглтонов, тем более в таких аспектах, как воспроизведение звука, недопустимо в проектах любого масштаба. С помощью этого
Примеры:
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 через дополнительные абстракции, а в тесте заменять абстракции на пустышки. К тому же так тест будет выполняться за минимум времени.
Leopotam
И в геймдев пытается пролезть этот ынтерпрайз, печально...Так какой смысл городить весь этот огород, если все в итоге сводится к синглтону? Внутренности реализации, сам язык все-равно привязаны к конечной платформе / движку, какой смысл вводить дополнительные промежуточные абстракции?
А еще в исходном комменте было показано как эта работа снимается с программиста и выносится на визуальный уровень инспектора в редакторе (решает дизайнер, а не программист). Такой апи сделан для того, чтобы у программиста остался контроль за происходящим из кода.
HexGrimm
Вы наверное не правильно поняли понятие «стиль жизни Singletone». Есть разница между внедрением абстракций в управляющий код, и прямым указанием типа для получения ссылки из статического свойства. Во втором случае у вас появляется сильная связанность кода. Такой код поддерживать тяжело, а в случае если дерево синглтонов растет, то такой код зачастую называют «макаронами» и выбрасывают если необходимы существенные изменения. Стиль жизни Singletone же, в свою очередь, только лишь означает, что не нужно каждый раз создавать новый экземпляр при внедрении той же абстракции в разные части проекта.
А речь идет именно про перенос этого модуля из проекта Unity3D в проект Unity3D. Смена набора технологий это уже совсем другой разговор и статья к нему не относится.
Я понимаю, что у вас возможно было такое требование к продукту и мой коментарий именно к вашему проекту не применим. Но вы же согласитесь, что при написании такого апи для работы с SoundController риск человеческого фактора остался, пусть вы и переложили его на дизайнера? (Например: на ранней стадии проекта дизайнер реализует звук для кнопок, а через 2 месяца, забыв про это, делает звук перехода между меню в том же канале.)
Какое значение имеет эта строка поясните?
Leopotam
Это называется — жестко формализованное апи, которое пишется один раз и редко меняется (библиотечная реализация без привязки к проекту). Если апи меняется — что-то пошло не так на стадии проектирования.
Это называется не «стиль жизни», а всего лишь lazy initialization, которая применима не только к этому «стилю жизни». Синглтон он и в африке синглтон, тонкости реализации находятся за пределами понятия паттерна поведения.
А риск есть всегда, в вашем случае — полное отсутствие контроля за количеством параллельных звуков и возможность спаунить подсистемы звука, хотя они должны быть единственными (ошибки со стороны программиста зачастую не менее часты, чем со стороны дизайнера). В моем случае — хоть убейся, но не сможешь наделать дублей, придется исхитрятся и втискивать свои хотелки в жесткие ограничения (дохлое железо скажет спасибо, а точнее, расширившаяся аудитория бюджеток).
habrahabr.ru/post/141477
megamozg.ru/post/4226
Не нужно делать что-то заведомо более сложным способом, чем его можно сделать. Сложность нужно добавлять по мере необходимости, иначе будет очередное монструозное решение с соответствующим высоким порогом входа на использование + повышение требований к железу для исполнения. Ынтерпрайз — это много абстракции, расширяемость важнее чем скорость исполнения конечного кода. GameDev — диаметрально противоположная область. Но это все IMHO, каждый пишет как хочет, это его право.
HexGrimm
Защититься от дублирования AudioListener в моем случае, не так сложно, и это так же можно инкапсулировать внутри модуля. (Как пример: добавив не публичную статику.) Это уже опционально, так как при регистрации в контейнере этот кейс уже исключается. Действительно, каждый пишет как хочет и это рождает полезные дискуссии.
Под lazy Optimization мы имеем в виду одно и тоже, это так — Отложенная инициализация. И в вашем и в моем случае объект инициализируется непосредственно перед первым обращением к нему. Однако в статье я использую другой термин.
KumoKairo
Прололжая тему аргументированных усложнений, хотелось бы уточнить по поводу двух вопросов (изначально хотел написать только про тестирование, но ваш ответ выше наводит на ещё одну мысль).
Касательно Singleton lifestyle — в каком случае, по вашему мнению, вы сможете использовать два или несколько разных компоновщиков в вашей игре и чем это может быть оправдано? (компоновщик — это Container, если я правильно понял по контексту?)
И собственно вопрос по тестированию — в статье вы несколько раз приводите доводы касательно удобства тестирования отдельных модулей. Как в вашем случае происходит процесс тестирования? Что конкретно и каким образом вы тестируете в «модуле» Sound Manager?
HexGrimm
Да, компоновщик — это контейнер. В области разработки игр мне ни разу не приходилось использовать более одного. Возможно, имеет смысл разгружать класс, в котором перечислены бинды, тк он должен знать обо всех namespace, классы которых используются и прятать часть биндов ниже по графу ссылок. Но из-за того, что придется создавать дополнительную зависимость от конкретного контейнера в нескольких местах вместо одного, я такого стараюсь не практиковать. Наша история перехода по контейнерам была такая: Strange — Zenject — Ninject.
Для тестирования мы используем набор: Editor Test Runner + NUnit + NSubstitude. Тесты к модулю лежат в директории модуля по доп пути Модуль/Tests/Editor/. Тесты пишем на то, что проверить вручную долго: Сохранение уровней звука и музыки + вкл\выкл между сессиями (сохранение в Prefs), тест значений по умолчанию, корректное использование данных из файла конфигурации, Чистка gameObjects после себя. В случае провала теста все равно чистимся с помощью атрибута с ITestAction из NUnit, если нужно. Вообще, в случае когда у вас и сверху и с низу интерфейсы у «модуля», то потенциально возможно покрытие 100%. CI у нас тесты не учитывает, Unity Cloud Build недавно вынес этот функционал и беты (если вывел), запускаем в ручную до сливания feature и после.
Тесты не делаем для классов, которые уже непосредственно работают с MonoBehaviour или ScriptableObject, так как тесты могут выполнятся долго и стараемся эту прослойку между кодом и юнити делать как можно тоньше.
Некоторые модули пишем по TDD, но для нас это скорее треннинг, чем парадигма.
KumoKairo
То есть по большей части предыдущий комментатор прав, и такой синглтон будет реальным синглтоном, только без глобальной точки доступа? Это оправдано тем, что в вашем проекте DI используется повсеместно, и этот Sound Manager просто удобно вклинить в уже поднятую систему? Или пришлось поднять DI ради Sound Manager? Потому что в случае отдельного заимствования вашего Sound Manager из гитхаба, придётся тащить все интерфейсы и «поднимать» DI в проекте, где его может не быть, либо ради простоты делать ту же глобальную точку доступа. По моему мнению, у вас получился не столько пример Sound Manager, сколько пример использования DI в Unity проектах на примере Sound Manager.
HexGrimm
В этом я и вижу основной минус паттерна и его ключевую оссобенность. Я не могу сказать что вы сравниваете термины одного уровня. Глобальная точка доступа и есть его недостаток. Хуже становится не Sound Manager'у, а классу, которому приходится содержать в себе указание типа. Попробуйте написать на него тест.
Если вы просто передаёте ссылки вниз по дереву зависимостей через конструкторы, это тоже DI, и Sound manager будет работать без контейнера (Если передавать только через конструктор, то у вас в любом случае получится дерево, без связей «многие ко многим») В нашем случае да, мы стараемся не держать конструкторы громоздкими и часть ссылок проставляем контейнером. Множество других модулей используются у нас в том же ключе, и это решается у нас на этапе планирования архитектуры. У нас проектов много, и такая мобильность «модулей» для нас — дополнительное преимущество.
Интерфейсы Sound Manager'а — это часть модуля, апи для работы с ним. Они и должны храниться в том же namespace, и передаваться в субмодуле. Если вам не хочется использовать DI, но вы хотите сохранить слабую связанность, то тогда хотя бы используйте для получения ссылки на интерфейс Service Locator. Это рекомендация, и конечно, IMHO.
Согласен, я сделал в статье упор именно на рефакторинг, и эти рекомендации применимы и к другим частям приложений. Я считаю правильным, написать о том что способ из реф статьи так же влечет за собой пассивное ухудшение качества кода проекта, и предложил способ как этого избежать. Я сам лично видел раз 5 код проектов, которые выросли из домашней разработки в нечто ценное, и затем тонули в бесконечном рефакторинге при первой же смене требований к продукту, из-за такого использования Singleton, по большей части.
KumoKairo
Не могли бы подробней описать один из подобных случаев, когда подобный Singleton с глобальной точкой доступа портил возможность адекватного рефакторинга? Это не наезд, мне действительно интересно прочитать про этот опыт.
HexGrimm
Если коротко: Для рефакторинга каждого синглтона в проекте вам придется так же модифицировать все классы, которые использовали его. И вместо того чтобы рефакторить класс как черный ящик, или модуль с устаканившимся апи, вам придется менять логику во множестве классов сразу. Один из неприятнейших моментов при этом — если есть синглтоны, значит нет тестов — значит при изменении класса ни кто не гарантирует, что все будет работать — чем больше классов подверглись изменениям, тем больше багов потенциально получится. В добавок, ситуацию усложнит получение данных через вложенные аксессоры. Чем больше вложенность, тем быстрее в команде выявить программиста для увольнения. Если у вас в коде несколько аксессоров, первый из которых статический, то вам придется решить сложную архитектурную задачу уже для множества классов, а не для 2х. Как пример:
Потребителю с таким обращением при рефакторинге не просто будет необходимо решить как получать ссылку на SoundManager, а еще и принять решение о достаточности полномочий для управления таким глубоким объектом AudioProcessor.
HexGrimm
Проблема хорошо разбирается в:
Refactoring to Patterns (Addison-Wesley Signature Series)
Joshua Kerievsky
Глава 6 (на сколько я помню)
Там написано куда понятнее, чем я бы смог сформулировать.