Skip to content

Blog Emi

O grach z kobiecej perspektywy

  • GameDev
  • Programowanie
  • Gry
  • Offtop
  • O mnie
Menu

Zenject – najlepsze praktyki i tipsy

Posted on 8 lutego, 20209 lutego, 2020 by Emi

Jako zakończenie mini serii o Zenject na blogu chciałabym podzielić się z Wami luźnymi uwagami na temat tego, jakie są najlepsze praktyki używania tej biblioteki oraz jakie mogą na nas czyhać pułapki. Jak większość rzeczy na tym świecie, Zenject nie jest idealny, a nierozważnie wprowadzony do projektu może zrobić więcej złego, niż dobrego.

Dobre praktyki – architektura kodu

Dobra organizacja instalatorów

Wspominałam już o tym podczas wprowadzenia do instalatorów, ale napiszę o tym jeszcze raz i to na samym początku – ponieważ jest to bardzo ważne! Dobrą zasadą jest tworzenie nowego instalatora dla każdej nowej funkcjonalności. Funkcjonalności gromadzimy w moduły i każdy moduł również dostaje swój installer – ale w nim dopuszczamy już tylko instalowanie instalatorów poszczególnych funkcjonalności – nie pozwalamy na tworzenie poszczególnych bindingów w klasach instalatorów modułów. Oczywiście „nie pozwalamy” nie oznacza, że da się to jakoś automatycznie wymusić – nie pozwalamy poprzez stosowanie się do tej zasady oraz przypominanie o niej innym programistom pracującym z nami nam projektem. Mając dobrze zorganizowane instalatory łatwiej jest kontrolować zależności cykliczne oraz pojawia się mniej wątpliwości co do umiejscowienia nowego bindingu, łatwiej jest również dzielić kontrakty między poszczególne konteksty.

Metoda wstrzykiwania

Rule of thumb – jeśli piszemy zwykłą klasę, to najlepiej jest wstrzykiwać zależności przez konstruktor, a jeśli skrypt Unity (czyli klasa dziedziczy po MonoBehaviour) – za pomocą udekorowanej atrybutem [Inject] metody o ustandaryzowanej w zakresie projektu nazwie – np. Init lub ConstructWithInjection. Zapewnia to dobrą testowalność kodu, gdyż łatwo jest w teście jednostkowym przekazać klasę – wydmuszkę, nie zawierającą żadnej implementacji. Standardowa nazwa takiej metody pomoże nowym programistom łatwiej odnaleźć się w projekcie. Dodatkowo skrypty Unity zyskują dobre miejsce na wykonanie operacji związanych z inicjalizacją obiektu – wiemy, że w momencie jej wykonania mamy już dostępne wszystkie niezbędne zależności.

Segregacja interfejsów

Tworząc kontrakty za pomocą biblioteki Zenject, w większości przypadków będziemy definiować różne interfejsy. To bardzo dobra praktyka – odwracamy zależności w naszym kodzie i sprawiamy, że jest luźniej powiązany. Ogólnie – pisząc kod dążymy do tworzenia jak najluźniejszych powiązań – najlepiej w ogóle ich unikać. Powiązania, czyli odwołania do innych miejsc w kodzie, znajdujących się poza kodem naszej klasy/naszego skryptu. Powodów, dla których warto mieć to na uwadze jest bardzo dużo i temat ten zasługuje na oddzielny post, ale w skrócie – dla łatwiejszego utrzymania i rozwijania aplikacji. Jest to szczególnie ważne podczas pisania kodu gier. Zasady działania skryptów, design, UI – to wszystko ulegnie zmianom wiele razy, zanim wydamy naszą grę, nawet najprostrzą. Takie po prostu jest życie – czasami design gry da się zweryfikować dopiero po tchnięciu w niego życia.

Pisząc klasę warto wstrzykiwać jej tylko tyle zależności, ile jest niezbędnych do jej działania. Żadna klasa nie powinna być zmuszona do posiadania wiedzy o metodach, których nie potrzebuje.

Bindowanie kontraktów dla singletonów

Konieczność używania wspólnej instacji danego obiektu w różnych klasach bez konieczności wprowadzania klasycznego singletonu jest głównym powodem dla którego ja pracę z każdym nowym projektem w Unity zaczynam od setupu Zenjecta. Nic tak nie produkuje spaghetti kodu jak intensywne korzystanie z wzorca singleton w zły sposób (`MojaKlasa.Instance). Używając DI i Zenjecta nasza klasa nie musi się przejmować tym, czy jej zależność to singleton czy też nie, my definiujemy to podczas tworzenia kontraktu. Warto dodać do tego parametr, który spowoduje natychmiastowe stworzenie obiektu singletona – tak, by nie działo się to dopiero w momencie, gdy będzie on potrzebny. Typowy binding singletona będzie zatem wyglądać następująco:

Container.Bind<IInterface>().To<Implementation>().AsSingle().NonLazy();

Ma to znaczenie nie tylko dla płynności działania gry (inicjalizacja w trakcie gameplayu może spowodować przycinkę). Jeśli nasz singleton ma za zadanie zrobić coś podczas inicjalizacji – np. uruchomić jakieś zadanie i nie jest on wstrzykiwany do żadnej klasy, dodanie NonLazy będzie konieczne, aby cokolwiek w ogóle się zadziało.

Nie wstrzykujmy wszędzie obiektu klasy Container!

Bardzo kusząca wydaje się możliwość wstrzykiwania do każdej klasy obiektu kontenera. Każda klasa może wtedy wyciągnąć sobie z niego co chce i nie musimy pisać tych długaśnych konstruktorów. Potrzeba nowej zależności? Wystarczy ją sobie wyciągnąć, nie trzeba dodawać nowego pola w klasie i argumentu w konstruktorze.

Taka implementacja wzorca Inversion of Control nosi nazwę Service Locator. Podejście to jest zdecydowanie niezalecane, z co najmniej kilku powodów:

  • tracimy jawną deklarację zależności w naszym kodzie – konstruktor z wyszczególnionymi klasami bardzo ładnie pokazuje, od ilu różnych zewnętrznych kawałków kodu będzie zależna nasza klasa. Gdy robi się bardzo tych wstrzykiwanych argumentów to jest znak, że być może nasza klasa urosła za bardzo, robi za dużo i gwałci zasadę SRP (Single Responsibility Principle) – zaczyna mieć dużo powodów do zmian i kod staje się coraz mocniej powiązany.
  • utrudniamy testowalność – zamiast zgrabnych mocków musimy w każdym teście zadbać o inicjalizację całego kontenera potrzebnego testowanej przez nas klasie.
  • ukrywając zależności w klasach, które również są wyciągane z kontenera, nie mamy zapewnionej dobrej kolejności resolvowania zależności – jest duża szansa, że spróbujemy wykonać operacje w momencie, kiedy dany obiekt nie będzie jeszcze miał wstrzykniętych zależności. Mówiąc wprost – kontener nie będzie miał pełnej wiedzy o zależnościach między obiektami klas, które tworzy i inicjalizuje.

Tracimy te wszystkie zalety, lecz wszystkie pozostałe wady wynikające z samego faktu użycia biblioteki do DI pozostaje – wszyscy członkowie zespołu muszą się nauczyć jej używać, należy utrzymywać konfigurację kontenera, a sam fakt używania go ma wpływ na wydajność – na przykład na szybkość ładowania się aplikacji.

Na co należy uważać

Bindowanie listy za pomocą BindInstance nie powoduje inicjalizacji elementów tej listy

Jeżeli chcemy aby elementy tej listy miały wstrzyknięte zależności, musimy przeiterować po nich i wywołać QueueForInject:

public class LevelManagementInstaller : MonoInstaller
{
	public List<LevelConfig> levelConfigs;

	public override void InstallBindings()
	{
		// Jeśli LevelConfig ma jakieś metody Init to nie zostaną one wywołane
		Container.BindInstance(levelConfigs);
		// Trzeba przeiterować
		foreach (var levelConfig in levelConfigs)
		{
			// Ten inject nastąpi dopiero w momencie, gdy już kontener będzie zainicjalizowany
			// Nie w momencie wywołania tej linijki 
			Container.QueueForInject(levelConfig);
		}
	}
}

Ogólnie w kodzie instalatorów nie można użyć zwykłego Resolve gdyż w momencie jego wywołania kontener dopiero się inicjalizuje – buduje drzewo zależności na podstawie którego finalnie ustali kolejność wstrzykiwania zależności w obiekty. QueueForInject zapewni nam inicjalizację obiektów dopiero, gdy kontener będzie gotowy.

Zenject nie jest Thread Safe

W normalnej pracy nad grą pewnie to nie będzie miało dla Was znaczenia, ale jeśli zechce na kilku wątkach uruchamiać instancje logiki gry i ją tickować (bo np. piszecie serwer do gier tak jak ja ostatnio w pracy 🙂 ) to może się okazać, że lecą najdziwniejsze błędy.

Inne bugi

Może się okazać, że traficie na buga w Zenject. Jak każdy kawałek software’u na tym świecie, również i Zenject nie jest biblioteką idealną. Pojawił się nowy projekt – Extenject, w którym oryginalny autor wdraża poprawki i usprawnienia do Zenjecta (nie może tego robić w oryginalnym projekcie z powodów prawnych, jakieś pozwy i inne takie weszły w grę). W każdym razie ja już się natknęłam na błąd, który tam był poprawiony, jednak nie używałam jeszcze tej wersji w żadnej grze komercyjnie.

To już koniec mini serii o Zenject, mam nadzieję, że te posty ułatwią Wam rozpoczęcie z nim pracy i pozwolą uniknąć niektórych problemów. Nie bójcie się go wprowadzić do projektu – na początku wydaje się, jakby wiązał się z wprowadzeniem sporego narzutu na kod (boilerplate), ale w miarę jak nabierzecie doświadczenia a projekt zostanie skonfigurowany, zobaczycie, że jego użycie znacznie poprawi jakość kodu i architekturę projektu.
Powodzenia!

Wszystkie posty związane z Zenject znajdziecie pod tagiem „Zenject”.

Posted in gamedev, programmingTagged czystykod, Unity, Zenject

Nawigacja wpisu

Zenject in depth cz.3 – Bindings & Injections
Programistka po biol-chemie cz.3 – O tym, jak prawie rzuciłam studia

Related Post

  • O Boiling Frogs 2020, o tym czym jest rzemiosło, o silnym postanowieniu i o tym, po co jest after party
  • Zenject in depth cz.3 – Bindings & Injections
  • Zdjęcie przedstawia wnętrze kuchni DaftCode, gdzie grupka ludzi pije drinki DaftShot jesień 2019 – czyli poznaj naszą firmę od środka

Dodaj komentarz Anuluj pisanie odpowiedzi

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Copyright © AllTopGuide 2023 • Theme by OpenSumo