Использование Singleton в Unity3D

в 11:03, , рубрики: C#, singleton, unity3d, untiy3D, практика, разработка

Вступление

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

В данной статье мы постарались не только описать такой подход к организации кода, как Singleton (в народе называемый паттерном программирования), но и рассмотреть наиболее комфортные и правильные подходы к обработке событий и поговорить об удобности кода в целом.

Итак, в этой статье мы затронем следующие моменты:

  1. Введение
  2. Как работает Singleton
  3. Реализация Singleton в Unity3D
  4. Взаимодействие с Singleton
  5. О плюсах и минусах Singleton
  6. Немного практических примеров
  7. Заключение

Как работает Singleton

Прежде чем начать разбираться в схеме работы паттерна Singleton, необходимо понять что это. Singleton (Синглтон) — некий менеджер, через который производится управление игровыми скриптами. Как правило, синглтоны сохраняются от сцены к сцене без повторной реинициализации (на подобии глобального объекта).

На простейшем примере работу Singleton можно объяснить следующим образом:
В игре присутствуют глобальные объекты (менеджеры), которые будут находиться в игре всегда и могут быть доступны из любого скрипта, что может быть полезно для создания классов управления музыкой, сетевыми функциями, локализацией и всем тем, что используется в единственном экземпляре. Помимо менеджеров в игре будут использоваться и множественные объекты: интерфейсы, игровые персонажи и объекты игрового мира. Все эти объекты будут плотно взаимодействовать с нашими менеджерами для достижения конечной цели.

Рассмотрим для примера организацию работы в мобильной игре:

В нашем случае Singleton — это объект переходящий от сцене к сцене, служащий для управления всеми объектами определенного типа в рамках игровой сцены (игры в целом).

На схеме ниже мы изобразили схему работы на примере мобильной пошаговой онлайн-игры:
image

Чтобы иметь полную картину, рассмотрим архитектуру этой игры. В данном случае помимо объектов Singleton у нас будут присутствовать следующие элементы:

  1. Объекты для подгрузки Singleton (Так называемый Bootstrap-класс)
  2. Объекты игровой логики (объекты управления сценариями)
  3. Контроллеры (Например: контроллер игрока)
  4. Модели данных (объекты для сериализации данных, получаемых с сервера)
  5. Объекты интерфейса
  6. Прочие статические игровые объекты

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

Реализация Singleton в Unity3D

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

Классы-Менеджеры

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

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//=============================================
//   Audio Manager
//=============================================
public class AudioManager: MonoBehaviour {
    public static AudioManager instance = null; // Экземпляр объекта

    // Метод, выполняемый при старте игры
    void Start () {
        // Теперь, проверяем существование экземпляра
	if (instance == null) { // Экземпляр менеджера был найден
	    instance = this; // Задаем ссылку на экземпляр объекта
	} else if(instance == this){ // Экземпляр объекта уже существует на сцене
	    Destroy(gameObject); // Удаляем объект
	}

        // Теперь нам нужно указать, чтобы объект не уничтожался
        // при переходе на другую сцену игры
	DontDestroyOnLoad(gameObject);

	// И запускаем собственно инициализатор
	InitializeManager();
    }

    // Метод инициализации менеджера
    private void InitializeManager(){
        /* TODO: Здесь мы будем проводить инициализацию */
    }
}

На примере выше мы создали основу для одного из игровых менеджеров (в нашем случае это менеджер Audio). Не обязательно проводить инициализацию через метод Start(). Вы также можете использовать для этого метод Awake(), чтобы ваш объект был готов еще до старта сцены.

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

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//=============================================
//   Audio Manager
//=============================================
public class AudioManager: MonoBehaviour {
    public static AudioManager instance = null; // Экземпляр объекта
    public static bool music = true; // Параметр доступности музыки
    public static bool sounds = true; // Параметр доступности звуков

    // Метод, выполняемый при старте игры
    void Start () {
        // Теперь, проверяем существование экземпляра
	if (instance == null) { // Экземпляр менеджера был найден
	    instance = this; // Задаем ссылку на экземпляр объекта
	} else if(instance == this){ // Экземпляр объекта уже существует на сцене
	    Destroy(gameObject); // Удаляем объект
	}

        // Теперь нам нужно указать, чтобы объект не уничтожался
        // при переходе на другую сцену игры
	DontDestroyOnLoad(gameObject);

	// И запускаем собственно инициализатор
	InitializeManager();
    }

    // Метод инициализации менеджера
    private void InitializeManager(){
        // Здесь мы загружаем и конвертируем настройки из PlayerPrefs
        music = System.Convert.ToBoolean (PlayerPrefs.GetString ("music", "true"));
	sounds = System.Convert.ToBoolean (PlayerPrefs.GetString ("sounds", "true"));
    }

    // Метод для сохранения текущих настроек
    public static void saveSettings(){
	PlayerPrefs.SetString ("music", music.ToString ()); // Применяем параметр музыки
	PlayerPrefs.SetString ("sounds", sounds.ToString ()); // Применяем параметр звуков
	PlayerPrefs.Save(); // Сохраняем настройки
    }
}

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

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//=============================================
//    Audio Muter Class
//=============================================
public class AudioMuter : MonoBehaviour {
    // Публичные параметры
    public bool is_music = false; // Это объект с музыкой?

    // Приватные параметры
    private AudioSource _as; // Audio Source
    private float base_volume = 1F; // Базовая громкость

    // Инициализация компонента при старте игры
    void Start () {
	_as = this.gameObject.GetComponent<AudioSource> (); // Получаем компонент AS
	base_volume = _as.volume; // Получаем базовую громкость из AS
    }

    // Каждый кадр мы проверяем параметры и устанавливаем громкость
    void Update () {
	// Для начала проверим, музыка это или нет
	if (is_music) {
	    _as.volume = (AudioManager.music)?base_volume:0F;
	} else {
	    _as.volume = (AudioManager.sounds)?base_volume:0F;
	}
    }
}

На примере выше мы создали компонент, позволяющий нам автоматически включать/отключать AudioSource на объекте на основе статичных полей music и sounds в нашем менеджере.

Примечание

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

Bootstrap-класс

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

Рассмотрим наш класс Boostrap-а:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//=============================================
//	Game Classes Loader
//=============================================
public class GameLoader : MonoBehaviour {
    // Ссылки на менеджеров
    public GameObject game_manager; // Game Base Manager
    public GameObject audio_manager; // Audio Manager
    public GameObject lang_manager; // Language Manager
    public GameObject net_manager; // Network Manager

    // Метод пробуждения объекта (перед стартом игры)
    void Awake () {
        // Инициализация игровой базы
	if (GameBase.instance == null) {
	    Instantiate (game_manager);
	}

	// Инициализация аудио менеджера
	if (AudioManager.instance == null) {
	    Instantiate (audio_manager);
	}

	// Инициализация менеджера языков
	if (LangManager.instance == null) {
	    Instantiate (lang_manager);
	}

	// Инициализация сетевого менеджера
	if (NetworkManager.instance == null) {
	    Instantiate (net_manager);
	}
    }
}

Теперь мы можем использовать Boostrap и добавлять в него новые префабы менеджеров без необходимости их размещения на каждой сцене игры.

Модели данных

Использование моделей данных необязательно, однако вы можете создать их для быстрой обработки данных с сервера и хранения данных в клиенте без необходимости повторных запросов. (к примеру для кеширования данных о пользователях в игре).

В нашем случае после запроса к серверу мы будем выгружать полученные данные в модели и обрабатывать их данные. Рассмотрим простейшую модель данных:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class responceModel{
	public bool complete = false; // Статус операции
	public string message = ""; // Сообщение об ошибке (в случае если complete = false)
}

На примере выше у нас изображена модель данных, которая будет служить для обработки базовых статусов, получаемых с сервера в формате JSON. Таким образом, когда мы обращаемся к нашему игровому серверу мы получаем 2 вида ответа:

При успешном обращении мы получаем ответ следующего вида:

{
    complete: true, // Статус операции
    data: {} // Объект с запрошенными данными
}

А при ошибке мы получаем ответ следующего вида:

{
    complete: false, // Статус операции
    message: "" // Сообщение об ошике
}

Таким образом мы можем парсить ответ сервера при помощи JSON десериализации и нашей модели данных:

responceModel responce = JsonUtility.FromJson<responceModel>(request.text); // Парсинг JSON
if(responce.complete){
    /* TODO: Делаем что-то с полученными данными */
    Debug.Log(responce.data);
}else{
    /* TODO: Выводим ошибку */
    Debug.Log(responce.message);
}

Контроллеры

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

Пример простого контроллера игрока:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//=============================================
//    PLAYER CONTROLLER
//=============================================
public class PlayerController : MonoBehaviour {
    // Публичные объекты
    [Header ("Player Body Parts")]
    public GameObject[] hairs;
    public GameObject[] faces;
    public GameObject[] special;

    // Инициализация компонента
    void Start () {
    }
	
    // Апдейт фрейма
    void Update () {
    }

    // Обновить на игроке его части тела
    public void updateParts (){
	// Работа с волосами
	for (int i = 0; i < hairs.Length; i++) {
		if (i == NetworkManager.instance.auth.player_data.profile_data.body.hairs) {
			hairs [i].SetActive (true);
		} else {
			hairs [i].SetActive (false);
		}
	}

	/* TODO: Тоже самое для других частей тела */
    }
}

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

Рассмотрим данную строку:

if (i == NetworkManager.instance.auth.player_data.profile_data.body.hairs){

Здесь мы видим, что идет сравнение индекса в цикле с идентификатором волос в модели данных игрока. Данная модель представлена в экземпляре объекта менеджера сети (NetworkManager), где был инициализирован объект для работы с авторизацией (auth), внутри которого размещены модели данных (player_data => profile_data => body).

Взаимодействие с Singleton

Для взаимодействия с менеджерами мы будем использовать либо экземпляр объекта (instance), либо прямое обращение для статических параметров.

Пример работы с instance:

public bool _hair = NetworkManager.instance.auth.player_data.profile_data.body.hairs;

На примере выше мы использовали свойство instance для получения данных о волосах игрока в менеджере NetworkManager.

Пример прямого взаимодействия со static-параметрами:

public bool _sounds = AudioManager.sounds;

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

О плюсах и минусах Singleton

Плюсы:

+ Нет необходимости постоянной настройки и описаний полей скриптов в инспекторе
+ К менеджерам можно обращаться через свойство instance
+ Удобный рефакторинг кода
+ Компактность кода

Минусы:

— Сильная зависимость кода
— Доступ только к скриптам-менеджерам в единственном экземпляре

Немного практических примеров

Использование делегатов
Мы можем сделать наш код более отзывчивым, добавив в менеджеры функции-делегаты. Таким образом для каждой функции может быть создан метод обратного вызова (callback).

Рассмотрим данный пример:

// Задаем функции-делегаты
public delegate void OnComplete();
public delegate void OnError(string message);

// Создаем наш метод, использующий делегаты
public void checkNumber(int number, OnComplete success, OnError fail){
    if(number<10){
         success(); // Вызываем метод OnComplete
    }else{
        fail("Вы ввели число большее 10!"); // Вызываем метод с ошибкой
    }
}

На простом примере выше мы создали метод, который вызываем функцию success, если параметр number был меньше 10 и функцию error, когда параметр был больше или равен 10 соответственно.

Использовать данный метод можно следующим способом:

public void testMethod(){
    int _number = Random.Range(0,50); // Случайное число

    // Вызываем созданный нами метод проверки числа
    checkNumber(_number, (()=>{ // Здесь вызывается метод Success
        /* TODO: Делаем что-то при успешном выполнении */
        Debug.Log("Все хорошо!");
    }), ((string text)=>{ // Здесь вызывается метод Fail
        Debug.Log(text); // Выводим текст, полученный в аргументе Callback функции
        testMethod(); // Перезапускаем метод до тех пор, пока число не станет <10
    }));
}

Таким образом мы можем создавать код с управляемым результатом. Теперь мы плавно переходим к примеру использования вместе с Singleton.

Делегаты в связке с Coroutine в Singleton

Для наиболее удобного и правильного взаимодействия с сервером мы можем использовать связку Coroutine-функций и делегатов, тем самым получая возможность отправлять асинхронные запросы и обрабатывать ответ сервера. Ниже мы подготовили пример NetworkManager-а с использованием Coroutine-функций и делегатов.

Рассмотрим данный пример NetworkManager-а:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//=============================================
//    Network Manager
//=============================================
public class NetworkManager : MonoBehaviour {
    // Публичные параметры
    public static NetworkManager instance = null; // Экземпляр менеджера
    public static string server = "https://mysite.com/api"; // URL сервера

    // Публичные ссылки на подобъекты менеджера
    public APIAuth auth; // Объект авторизации
    public APIUtils utils; // Объект утилит

    // Инициализация менеджера
    void Awake () {
        // Проверяем экземпляр объекта
	if (instance == null) {
	    instance = this;
	} else if(instance == this){
	    Destroy(gameObject);
	}

        // Даем понять движку, что его не нужно уничтожать
	DontDestroyOnLoad(gameObject);

	// Инициализируем нашего менеджера
	InitializeManager();
    }

    // Инициализация менеджера
    public void InitializeManager(){
	auth = new APIAuth (server + "/auth/"); // Подключаем подобъект авторизации
	utils = new APIUtils (server + "/utils/"); // Подключаем подобъект утилит
    }
}

//=============================================
//    API Auth Manager
//=============================================
public class APIAuth{
    // Приватные параметры
    private string controllerURL = ""; // Controller URL

    //=============================================
    //   Конструктор объекта
    //=============================================
    public APIAuth(string controller){
	controllerURL = controller;
    }

    //=============================================
    //    Метод для авторизации
    //=============================================
    public delegate void OnLoginComplete();
    public delegate void OnLoginError(string message);
    public IEnumerator SingIn(string login, string password, OnLoginComplete complete, OnLoginError error){
        // Формируем данные для отправки
	WWWForm data = new WWWForm();
	data.AddField("login", login);
	data.AddField("password", password);
	data.AddField("lang", LangManager.language);

	// Отправляем запрос на сервер
	WWW request = new WWW(controllerURL + "/login/", data);
	yield return request;

	// Обрабатываем ответ сервера
	if (request.error != null) { // Ошибка отправки запроса
	    error ("Не удалось отправить запрос на сервер");
	} else { // Ошибок при отправке не было
	    try{
                responceModel responce = JsonUtility.FromJson<responceModel>(request.text);
		if(responce.complete){
		    complete(); // Вызываем Success Callback
		}else{
		    error (responce.message); // Do error
		    Debug.Log("API Error: " + responce.message);
		}
	    }catch{
		error ("Не удалось обработать ответ сервера");
		Debug.Log("Ошибка обработки ответа сервера. Данные ответа: " + request.text);
	    }
	}
    }
    

    /* TODO: Здесь будут остальные методы для работы с авторизацией */
}

//=============================================
//    Теперь создаем подобъект утилит по образу и подобию авторизации
//=============================================
public class APIUtils{
    private string controllerURL = "";

    // Аналогичный конструктор класса
    public APIUtils(string controller){
        controllerURL = controller;
    }

    //=============================================
    //    Проверка версии клиента игры
    //=============================================
    public delegate void OnClientVersionChecked();
    public delegate void OnClientVersionError(string message);
    public IEnumerator CheckClientVersion(string version, OnClientVersionChecked complete, OnClientVersionError error){
        // Создаем данные
        WWWForm data = new WWWForm();
	data.AddField("version", version);
	data.AddField("lang", LangManager.language);

        // Отправляем запрос
	WWW request = new WWW(controllerURL + "/checkVersion/", data);
	yield return request;

	// Обрабатываем ответ
	if (request.error != null) {
	    error ("Не удалось отправить запрос на сервер");
	} else {
	    try{
		responceModel responce = JsonUtility.FromJson<responceModel>(request.text);
		if(responce.complete){
		    complete();
		}else{
		    error (responce.message);
		    Debug.Log("API Error: " + responce.message);
		}
	     }catch{
	         error ("Не удалось обработать ответ сервера");
		 Debug.Log("Ошибка обработки ответа сервера. Данные ответа: " + request.text);
	     }
	}
    }
}

Теперь мы можем использовать это по назначению:

// Простая функция для вызова проверки
public void checkMyGame(){
    StartCoroutine(NetworkManager.instance.utils.CheckClientVersion(Application.version, 
    (()=>{ // Если все прошло успешно
        /* TODO: Здесь мы выполняем загрузку игры после успешной проверки версии игры */
    }), ((string msg) => { // Если возникла ошибка
	/* TODO: Здесь мы просим пользователя обновить версию клиента игры */
        Debug.Log(msg);
    })));
}

Таким образом, вы можете выполнять код NetworkManager и управлять его методами при помощи Callback-функций из любой сцены игры.

Заключение

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

Список полезных материалов:

Автор: Илья Расторгуев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js