MonoGame #6 – ładowanie interfejsu z pliku i repozytorium

Czas na kolejną aktualizację. Nieprzerwanie pracuję w wolnych chwilach nad narzędziem do interfejsu i dzisiaj mam aż dwa ogłoszenia parafialne.

Po pierwsze, w ostatnim tygodniu postanowiłem zamieścić projekt w postaci publicznego repozytorium. Zapraszam więc do śledzenia postępów pod następującym adresem.

https://github.com/BinarForge/MonoGameUI

Wszelkie uwagi i komentarze mile widziane! Trzeba jednak zaznaczyć, że to bardziej ćwiczenie niż profesjonalny projekt. 🙂

Druga sprawa to faktyczny postęp. Jako że Rzymu „z palca” nie zbudowano, tak i intefejsu się nie powinno. Oczywiście jak to wynika z poprzedniego wpisu API nam to umożliwia i nie ma w tym nic złego. Dlaczego jednak nie uprościć by sprawy niedużym nakładem pracy?

Postanowiłem opracować bardzo prostą składnię, która pozwala definiować nasz interfejs. Spędziłem i wciąż spędzam sporo czasu przy stronach WWW, więc niech sposób opisywania UI brzmi podobnie. Dosyć popularny jest ostatnimi czasy XAML i miałem z nim styczność używając Xamarin’a, ale nie chciałbym przywiązywać się do innej technologii.

Zacząłem więc rzeźbić prosty parser (translator w gruncie rzeczy). Co to znaczy? Dla każdego z elementów dostępnych w mojej bibliotece (kontenery, przycisk, pole tekstowe, …) zdefiniowałem nazwę elementu i atrybuty. Platforma .net ułatwia mi życie, bo wczytywanie pliku XML jest dostępny „od ręki”, nie muszę się tym przejmować.

W tym miejscu notatka, że zacząłem podejście od plików JSON, ale tak jak bardzo lubię ten format, nie okazał się w tym wypadku bardzo pomocny. Skoro operuję na konkretnym typach, w pliku musiały znajdować się konkretne typy używane przy deserializacji takiego pliku. Poza krzaczkowym wyglądem oznaczało to również trzymanie nieistotnych dla użytkownika informacji w sporym i słabo czytelnym pliku. Skierowałem się więc w stronę niemniej popularnego XML, który zresztą nawet bardziej przypomina szablony stron www i nadal pozwala na zdefiniowanie jasnej hierarchii wśród elementów.

Załóżmy więc, że tworzymy następujący plik XML:

<monoui>
  <horizontal-container>
    <text-field weight="1" bg-color="#776655" text="Hello again..." text-color="#fff" />
    <text-field weight="3" bg-color="#449" text="Down there!" text-color="#aabbee" text-align="center" />
  </horizontal-container>
</monoui>

Założyłem, że cały interfejs będzie trzymany w głównym elemencie o oryginalnej nazwie monoui. Następnie wewnątrz będzie kontener poziomy (ang. horizontal) i w nim dwa panele z tekstem. Atrybuty definiują treść i kolory, a także pozycjonowanie tekstu (text-align). Całość po odpaleniu powinna dać nam podobny obrazek:

monogame6_0

Więc jaka jest droga od pliku tekstowego do interfejsu na ekranie? Do projektu dołączam klasę UILoader a w niej metoda, która za parametr bierze ścieżkę do pliku XML.

public UiContainer FromXml(string _uiXml)
{
	try {
		_xmlDocument.LoadXml(_uiXml);
	}
	catch {
		return null;
	}

	UiContainer uiTree = new UiContainer();
	var xmlUiDescription = _xmlDocument.ChildNodes[0];

	if (_xmlDocument.ChildNodes == null || _xmlDocument.ChildNodes.Count != 1 || xmlUiDescription.Name != _rootElementName)
		return null;

	for (var i = 0; i < xmlUiDescription.ChildNodes.Count; i++) {
		var childElement = new UiNode();
		HandleElement(ref childElement, xmlUiDescription.ChildNodes[i]);
		uiTree.AppendChild(childElement);
	}

	return uiTree;
}

Obiekt _xmlDocument to jak już wcześniej wspomniałem wykorzystanie platformy .net do załadowania struktury pliku XML. W razie gdyby coś poszło nie tak, na przykład plik ma zły format lub w ogóle nie jest czytelny (dla programu), w obecnej iteracji po prostu przerywamy działanie i nie zwracamy nic (null).

Następnie widać kolejne założenie, czyli, że element najwyższego poziomu to UiContainer, czyli niewidzialna abstrakcja, która służy za korzeń struktury danych jaką jest drzewo. Widać warunek, który sprawdza czy całość znajduje się w elemencie o odpowiedniej nazwie. (_rootElementName, czyli u mnie monoui). W następnym i zarazem ostatnim kroku przerabiany jest rekursywnie każdy podrzędny element i dołączany do drzewa. Metoda HandleElement to funkcja rekursywna, która potrzebuje referencji do obiektu i fragmentu pliku XML. Co z tą informacją robi? Spójrzmy na listing:

private void HandleElement(ref UiNode node, XmlNode xmlNode)
{
	if (_elementMapper.ContainsKey(xmlNode.Name))
		node = _elementMapper[xmlNode.Name].Parse(xmlNode, _attributeTranslator);

	var container = node as UiContainer;
	if (container == null)
		return;

	for(var i=0; i<xmlNode.ChildNodes.Count; i++) {
		var childElement = new UiNode();
		HandleElement(ref childElement, xmlNode.ChildNodes[i]);
		container.AppendChild(childElement);
	}
}

Wprowadziłem tutaj koncept element mapperów i attribute parserów. Są to obiekty, których klasa odpowiada danemu typowi elementu. Łączy je interfejs IElementParser, który narzuca istnienie metody Parse, która wykonana może być tylko jeżeli w słowniku istnieje przetwarzany element. W drugim kroku funkcja wywołuje samą siebie tak długo aż xml’owy opis drzewa zawiera elementy podrżedne.

Czyli kontynuując, dla przykładowego kontenera poziomego (horizontal), słownik elementów będzie wyglądał następująco:

private Dictionary<string, IElementParser> _elementMapper = new Dictionary<string, IElementParser>
{
        (...)
	{ "horizontal-container", new ElementParser<HorizontalContainer>() },
        (...)
};

Klasa ElementParser nie jest specjalnie ekscytująca, gdyż jej głównym zadaniem jest utworzenie instancji obiektu zgodnie z podanym w ostrych nawiasach typem. Właśnie dlatego wcześniej wspomniałem, że jest to swego rodzaju translator. Przekłada ciąg znaków (nazwę) na typ obiektu. Co jeszcze dzieje się wewnątrz parsera?

internal interface IElementParser
{
	UiNode Parse(XmlNode xmlNode, Dictionary<string, IAttributeParser> _attributeTranslator);
}

internal class ElementParser<T> : IElementParser where T : UiNode, new()
{
	public UiNode Parse(XmlNode elementInfo, Dictionary<string, IAttributeParser> _attributeTranslator)
	{
		var element = new T();

		for (int i = 0; i < elementInfo.Attributes.Count; i++) {
			var attr = elementInfo.Attributes[i];

			if (!_attributeTranslator.ContainsKey(attr.Name))
				continue;

			var property = element.GetType().GetProperty(_attributeTranslator[attr.Name].GetPropertyName());
			if (property == null)
				continue;

			property.SetValue(element, _attributeTranslator[attr.Name].ParseAttribute(attr.Value), null);
		}

		return element;
	}
}

Powyżej widać wspomniany przed chwilą interfejs no i sam parser. Wewnątrz metody Parse mamy XMLowy opis elementu i translator atrybutów, który ideowo jest podobny do słownika elementów. Dotyczy jednak atrybutów w rozumieniu XML, czyli opcji typu kolor, tekst czy waga w rozumieniu tworzonego interfejsu. Parser iteruje więc przez kolejne atrybuty przetwarzanego elementu i sprawdza czy są zawarte w słowniku. Jeżeli tak, sprawdzamy czy przetwarzany element drzewa interfejsu (kontener w powyższym przykładzie) zawiera opisane ciągiem znaków pole. (GetProperty). Czyli attributeTranslator to zestaw zasad, z których każda tłumaczy nazwę atrybutu na nazwę pola z klasy elementu i dodatkowo definiuje sposób przetwarzania wartości takiego atrybutu.

Czyli przykładowo, dla pola odpowiedzialnego za kolor tła (BgColor), zdefiniujemy następującą zasadę:

private Dictionary<string, IAttributeParser> _attributeTranslator = new Dictionary<string, IAttributeParser>
{
	{ "bg-color", new ColorParser("BgColor") },
	(...)
}

Mamy więc nazwę atrybutu spodziewanego w pliku XML (bg-color), obiekt odczytujący wartość (ColorParser) i przy okazji nazwę elementu (BgColor) żeby przetworzoną wartość gdzieś utrwalić. Jeżeli kojarzysz wzorce projektowe, to opisane podejście podpada pod Strategię. Przetwarzając plik z danymi parser zastosuje odpowiednią strategię (klasę) w zależności od napotkanego elementu.

Być może wygląda to nieco skomplikowanie, ale jak dotąd z mojej perspektywy to działa. Rzuć okiem na przykładowy parser (dla koloru):

public class ColorParser : BaseAttributeParser
{
	public ColorParser(string propertyName)
	{
		_propertyName = propertyName;
	}

	public override object ParseAttribute(string attributeValue)
	{
		return HexColor.FromString(attributeValue);
	}
}

Metoda ParseAttribute nie wie jakiego typu będzie zwracać obiekt ale wcale nie musi. Zawdzięczam to elastyczności C#, mogę zwrócić co zechce a dzięki poprawnie skonfigurowanemu słownikowi parserów odczytana wartość i tak trafi tam gdzie powinna. W przypadku koloru posługuję się napisaną klasą pomocniczą, która zamienia ciąg znaków opisujący kolor w formacie hexadecymalnym (jak w przeglądarkach www) na właściwy dla silnika MonoGame obiekt typu Color. Idąc dalej tą ścieżką można tworzyć dowolne sposoby obsługi każdego typu elementów. Na dzień dzisiejszy stworzyłem ich kilka i tak wygląda mój słownik w tym momencie:

private Dictionary<string, IAttributeParser> _attributeTranslator = new Dictionary<string, IAttributeParser>
{
	{ "bg-color", new ColorParser("BgColor") },
	{ "text-color", new ColorParser("TextColor") },
	{ "text", new TextParser("Text") },
	{ "weight", new FloatParser("Weight") },
	{ "margin", new RectangleParser("Margin") },
	{ "text-align", new TextAlignmentParser("TextAlignment") },
	{ "position", new PositionParser("Position") }
};

Pełny kod źródłowy oczywiście w repozytorium jeśli ktoś jest ciekaw szczegółów. Jak widać kilka opcji jest już dostępnych, można tworzyć jakieś szkice interfejsów! Tak niewiele logiki i dobroci języka C# pozwoliły mi na zbudowanie dosyć elastycznego już narzędzia do definiowania interfejsu. Spójrzmy na nieco bardziej rozbudowany przykład:

<monoui>
	<horizontal-container>
		<vertical-container weight="80">
			<text-field weight="75"  />
			<horizontal-container weight="25">
				<vertical-container weight="25" bg-color="#222">
					<text-field weight="1" margin="10 10 5 5" bg-color="#334" />
					<text-field weight="1" margin="10 5 5 10" bg-color="#334" />
				</vertical-container>
				<vertical-container weight="25" bg-color="#222">
					<text-field weight="1" margin="5 10 10 5" bg-color="#334" 
						text="the button" text-align="center" text-color="#cc6677"  />
					<text-field weight="1" margin="5 5 10 10" bg-color="#334" />
				</vertical-container>
			</horizontal-container>
		</vertical-container>
		<vertical-container weight="20" bg-color="#333">
			<text-field weight="2" margin="10" bg-color="#444" text="Hello, world!" 
							text-color="#77dd44" text-align="center" />
			<text-field weight="1" margin="10" bg-color="#444" />
			<text-field weight="1" margin="10" bg-color="#444" />
			<text-field weight="1" margin="10" bg-color="#444"/>
			<text-field weight="1" margin="10" bg-color="#444" />
		</vertical-container>
	</horizontal-container>
</monoui>

… który po przetworzeniu da nam następujący obrazek:

monogame6_1

Brzmi prosto w obsłudze? A może przekombinowałem? Wymyślam koło od nowa? Zapraszam do konstruktywnej krytyki i własnych przemyśleń a ja tymczasem wracam do pracy. Wraz z dzisiejszą aktualizacją zapraszam do zabawy i testowania repozytorium z projektem. Jest tam projekt przykładowy, który zawiera poglądowe użycia biblioteki, gotowe do sprawdzania bez żadnych zmian.