
Hoje vou falar sobre como alguns projetos da Pixonic chegaram ao que há muito tempo é a norma para todo o front-end global - links reativos.
A grande maioria de nossos projetos são escritos em Unity 3D. E, se outras tecnologias de cliente com reativo estão indo bem (MVVM, Qt, milhões de frameworks JS), e isso é dado como certo, o Unity não tem nenhuma vinculação incorporada ou geralmente aceita.
A essa altura, provavelmente alguém tinha uma pergunta: “Por quê? Não usamos isso e vivemos bem. ”
Houve razões. Mais precisamente, havia problemas, uma das soluções para os quais poderia ser o uso de tal abordagem. Como resultado, tornou-se um. E os detalhes estão sob o corte.
Em primeiro lugar, sobre o projeto, cujos problemas exigiam tal solução. Claro, estamos falando de War Robots - um projeto gigante com muitas equipes diferentes de desenvolvimento, suporte, marketing, etc. Agora estamos interessados em apenas dois deles: a equipe de programadores clientes e a equipe de interface do usuário. A seguir, para simplificar, iremos chamá-los de "código" e "layout". Acontece que algumas pessoas estão envolvidas no design e layout da IU, enquanto a “revitalização” de tudo isso é feita por outras. Isso é lógico e, em minha experiência, encontrei muitos exemplos semelhantes de organização de equipes.
Percebemos que, com o fluxo crescente de recursos no projeto, a interação de código e layout se torna um lugar de impasses e gargalos. Os programadores estão esperando por widgets prontos para o trabalho, designers de layout - por algumas modificações do código. Sim, muitas coisas aconteceram durante essa interação. Em suma, às vezes se transformava em caos e procrastinação.
Deixe-me explicar agora. Dê uma olhada no exemplo clássico de widget simples - especialmente o método RefreshData. O resto do clichê eu apenas adicionei para plausibilidade, e não vale muita atenção.
public class PlayerProfileWidget : WidgetBehaviour
{
[SerializeField] private Text nickname;
[SerializeField] private Image avatar;
[SerializeField] private Text level;
[SerializeField] private GameObject hasUpgradeMark;
[SerializeField] private Button upgradeButton;
public void Initialize(ProfileService profileService)
{
RefreshData(profileService.Player);
upgradeButton.onClick
.Subscribe(profileService.UpgradePlayer)
.DisposeWith(Lifetime);
profileService.PlayerUpgraded
.Subscribe(RefreshData)
.DisposeWith(Lifetime);
}
private void RefreshData(in PlayerModel player)
{
nickname.text = player.Id;
avatar.overrideSprite = Resources.Load<Sprite>($"Avatars/{player.Avatar}_Small");
level.text = player.Level.ToString();
hasUpgradeMark.SetActive(player.HasUpgrade);
}
}
Este é um exemplo de link estático de cima para baixo. No componente do GameObject superior (na hierarquia), você vincula componentes dos tipos correspondentes de objetos inferiores. Tudo aqui é extremamente simples, mas não muito flexível.
A funcionalidade dos widgets está em constante expansão com o advento de novos recursos. Vamos imaginar. Agora deve haver uma borda ao redor do avatar, cuja aparência depende do nível do jogador. Ok, vamos adicionar um link para a imagem do quadro e imergir o sprite correspondente ao nível lá, então adicionar a configuração para combinar o nível e o quadro e dar tudo ao layout. Feito.
Um mês se passou. Agora, um ícone de clã aparece no widget do jogador, se ele for um membro. E você também precisa registrar o título que ele tem lá. E o apelido precisa ser pintado de verde se houver uma atualização. Além disso, agora estamos usando TextMeshPro. E também ...
Bem, essa é a ideia. O código se torna mais e mais, se torna mais e mais complicado, coberto por várias condições.
Existem várias opções para trabalhar aqui. Por exemplo, o programador modifica o código do widget, dá as mudanças no layout. Eles adicionam e vinculam componentes a novos campos. Ou vice-versa: o layout pode chegar com antecedência, o próprio programador fará o link de tudo o que for necessário. Normalmente, há várias outras iterações de correções. Em qualquer caso, esse processo não é paralelo. Ambos os contribuidores estão trabalhando no mesmo recurso. E mesclar pré-fabricados ou cenas ainda é um prazer.
Para engenheiros, tudo é simples: se você vê um problema, você tenta resolvê-lo. Então tentamos. Como resultado, chegamos à ideia de que era necessário estreitar a frente de contato entre as duas equipes. E os padrões reativos estreitam essa frente a um ponto - o que é comumente chamado de View Model. Para nós, atua como um contrato entre código e layout. Quando eu entrar em detalhes, o significado do contrato ficará claro e por que ele não bloqueia a operação paralela de duas equipes.
No momento em que acabamos de pensar sobre tudo isso, havia várias soluções de terceiros. Estávamos pensando em Unity Weld, Peppermint Data Binding, DisplayFab. Todos eles tinham seus prós e contras. Mas uma das falhas fatais para nós era comum - desempenho ruim para nossos propósitos. Eles podem funcionar bem em interfaces simples, mas naquela época não podíamos evitar a complexidade das interfaces.
Como a tarefa não parecia proibitivamente difícil, e mesmo com experiência relevante, decidiu-se implementar um sistema de ligação reativa dentro do estúdio.
As tarefas eram as seguintes:
- Atuação. O próprio mecanismo de propagação das mudanças deve ser rápido. Também é desejável reduzir a carga no GC para que você possa usar tudo isso mesmo no jogo, onde os congelamentos não são nada felizes.
- Autoria conveniente. Isso é necessário para que os caras da equipe de UI possam trabalhar com o sistema.
- API conveniente.
- Extensibilidade.
De cima para baixo, ou descrição geral
A tarefa é clara, os objetivos são claros. Vamos começar com o "contrato" - o ViewModel. Qualquer pessoa deve ser capaz de formá-lo, o que significa que a implementação do ViewModel deve ser o mais simples possível. É basicamente um conjunto de propriedades que determinam o estado de exibição atual.
Para simplificar, limitamos o conjunto de tipos de propriedade com valores para bool, int, float e string, tanto quanto possível. Isso foi ditado por várias considerações ao mesmo tempo:
- Serializar esses tipos no Unity é fácil;
- , -, . , Sprite -, PlayerModel , ;
- , .
Todas as propriedades estão ativas e informam os assinantes sobre as alterações em seus valores. Esses valores nem sempre estão presentes - há apenas eventos na lógica de negócios que precisam ser visualizados de alguma forma. Neste caso, existe um tipo de propriedade sem valor - evento.
Claro, você também não pode prescindir de coleções em interfaces. Portanto, há também um tipo de propriedade de coleção. A coleção notifica os assinantes sobre qualquer alteração em sua composição. Os elementos da coleção também são ViewModels de uma determinada estrutura ou esquema. Este esquema também é descrito no contrato durante a edição.
No editor ViewModel é assim:

Deve-se observar que as propriedades podem ser editadas diretamente no inspetor e em tempo real. Isso permite que você veja como o widget (ou janela, cena ou qualquer outra coisa) se comportará em tempo de execução, mesmo sem código, o que é muito conveniente na prática.
Se ViewModel é a parte superior do nosso sistema de vinculação, a parte inferior são os chamados aplicadores. Estes são os assinantes finais das propriedades ViewModel que fazem todo o trabalho:
- Habilite / desabilite GameObject ou componentes individuais mudando o valor da propriedade booleana;
- Altere o texto no campo dependendo do valor da propriedade string;
- O animador é iniciado, seus parâmetros são alterados;
- Substitua o sprite desejado da coleção por índice ou chave de string.
Vou parar por aqui, já que o número de aplicativos é limitado apenas pela imaginação e pela gama de tarefas que você resolve.
Esta é a aparência de alguns aplicadores no editor:


Para obter mais flexibilidade, os adaptadores podem ser usados entre propriedades e aplicadores. Essas são entidades para transformar propriedades antes de serem aplicadas. Existem também muitos diferentes:
- Booleano - por exemplo, quando você precisa inverter uma propriedade booleana ou retornar verdadeiro ou falso dependendo de um valor de um tipo diferente (eu quero uma borda dourada quando o nível estiver acima de 15).
- Aritmética . Nenhum comentário aqui.
- Operações em coleções : inverter, pegar apenas parte da coleção, classificar por chave e muito mais.
Novamente, pode haver uma grande variedade de opções de adaptadores diferentes, então não irei continuar.



Na verdade, embora o número total de diferentes aplicadores e adaptadores seja grande, o conjunto básico usado em todos os lugares é muito limitado. Uma pessoa que trabalha com conteúdo precisa estudar este conjunto primeiro, o que aumenta um pouco o tempo de treinamento. No entanto, você precisa dedicar tempo a isso uma vez, para que não haja grandes problemas aqui. Além disso, temos um livro de receitas e documentação sobre o assunto.
Quando falta algo ao layout, os programadores adicionam os componentes necessários. Ao mesmo tempo, a grande maioria dos aplicadores e adaptadores são universais e ativamente reutilizados. Separadamente, deve-se destacar que ainda temos aplicadores que trabalham na reflexão via UnityEvent. Eles são aplicáveis nos casos em que o aplicador necessário ainda não foi implementado ou sua implementação é impraticável.
Isso certamente aumenta o trabalho da equipe de layout. Mas, em nosso caso, eles estão até satisfeitos com o grau de liberdade e independência dos programadores que obtêm. E se o trabalho aumentou do lado do layout, do lado do código tudo agora é muito mais fácil.
Vamos voltar ao exemplo PlayerProfileWidget. É assim que agora se parece em nosso projeto hipotético na forma de um apresentador, porque não precisamos mais de um Widget na forma de um componente e podemos obter tudo do ViewModel em vez de vincular tudo diretamente:
public class PlayerProfilePresenter : Presenter
{
private readonly IMutableProperty<string> _playerId;
private readonly IMutableProperty<string> _playerAvatar;
private readonly IMutableProperty<int> _playerLevel;
private readonly IMutableProperty<bool> _playerHasUpgrade;
public PlayerProfilePresenter(ProfileService profileService, IViewModel viewModel)
{
_playerId = viewModel.GetString("player/id");
_playerAvatar = viewModel.GetString("player/avatar");
_playerLevel = viewModel.GetInteger("player/level");
_playerHasUpgrade = viewModel.GetBoolean("player/has-upgrade");
RefreshData(profileService.Player);
viewModel.GetEvent("player/upgrade")
.Subscribe(profileService.UpgradePlayer)
.DisposeWith(Lifetime);
profileService.PlayerUpgraded
.Subscribe(RefreshData)
.DisposeWith(Lifetime);
}
private void RefreshData(in PlayerModel player)
{
_playerId.Value = player.Id;
_playerAvatar.Value = player.Avatar;
_playerLevel.Value = player.Level;
_playerHasUpgrade.Value = player.HasUpgrade;
}
}
No construtor, você pode ver o código obtendo propriedades de ViewModel. Sim, neste código, as verificações são omitidas para simplificar, mas existem métodos que lançam uma exceção se não encontrarem a propriedade desejada. Além disso, temos várias ferramentas que fornecem uma garantia bastante forte de que os campos obrigatórios estão presentes. Eles são baseados na validação de ativos, sobre a qual você pode ler aqui .
Não vou entrar em detalhes de implementação, pois vai exigir muito texto e seu tempo. Se houver inquérito público, é melhor abri-lo em artigo separado. Só direi que a implementação não é muito diferente do mesmo Rx, só que tudo é um pouco mais simples.
A tabela mostra os resultados de um benchmark que cria 500 formulários com InputField, Text e Button associados a um modelo de propriedade e uma função de ação.

Como conclusão, posso informar que os objetivos acima foram alcançados. Os benchmarks comparativos mostram ganhos de memória e tempo em relação às opções mencionadas. Conforme a equipe de layout e as pessoas de outros departamentos que lidam com o conteúdo se tornam mais familiares, o atrito e o bloqueio tornam-se cada vez menores. A eficiência e a qualidade do código aumentaram e agora muitas coisas não requerem a intervenção do programador.