Storyboards para iOS: análise dos prós e contras, melhores práticas



A Apple criou os Storyboards para que os desenvolvedores possam visualizar as telas dos aplicativos iOS e os relacionamentos entre eles. Nem todo mundo gostou desta ferramenta, e por boas razões. Encontrei muitos artigos criticando os Storyboards, mas não encontrei uma análise detalhada e imparcial de todos os prós e contras, levando em consideração as melhores práticas. No final, decidi escrever esse artigo pessoalmente.

Vou tentar analisar em detalhes as desvantagens e vantagens do uso de Storyboards. Depois de ponderá-los, você pode tomar uma decisão significativa, sejam eles necessários no projeto ou não. Esta decisão não precisa ser radical. Se, em algumas situações, os Storyboards criam problemas, em outras, seu uso é justificado: ajuda a resolver tarefas de maneira eficaz e a escrever códigos simples e de fácil manutenção.

Vamos começar com as deficiências e analisar se todas elas ainda são relevantes.

Desvantagens


1. Os storyboards têm dificuldade em gerenciar conflitos ao mesclar alterações


Storyboard é um arquivo XML. É menos legível que o código, portanto, a resolução de conflitos é mais difícil. Mas essa complexidade também depende de como trabalhamos com o Storyboard. Você pode simplificar bastante sua tarefa se seguir as regras abaixo:

  • Não coloque a interface do usuário inteira em um único Storyboard, divida-o em vários menores. Isso permitirá distribuir o trabalho no Storyboards entre os desenvolvedores sem o risco de conflitos e, no caso de sua inevitabilidade, simplificará a tarefa de resolvê-los.
  • Se você precisar usar a mesma Visualização em vários locais, selecione-a em uma subclasse separada com seu próprio arquivo Xib.
  • Faça confirmações com mais frequência, pois é muito mais fácil trabalhar com alterações que vêm em pedaços pequenos.

O uso de vários Storyboards em vez de um torna impossível ver o mapa inteiro do aplicativo em um arquivo. Mas muitas vezes isso não é necessário - apenas a parte específica em que estamos trabalhando no momento é suficiente.

2. Os storyboards impedem a reutilização de código


Se estamos falando sobre o uso apenas de Storyboards sem Xibs no projeto, certamente haverá problemas. No entanto, Xibs, na minha opinião, são elementos necessários ao trabalhar com Storyboards. Graças a eles, você pode criar facilmente Visualizações reutilizáveis, que também são convenientes para trabalhar no código.

Primeiro, crie a classe XibView base, responsável por renderizar o UIView criado no Xib no Storyboard:

 @IBDesignable class XibView: UIView { var contentView: UIView? } 

XibView carregará o UIView do Xib no contentView e o adicionará como sua subview. Fazemos isso no método setup() :

 private func setup() { guard let view = loadViewFromNib() else { return } view.frame = bounds view.autoresizingMask = [.flexibleWidth, .flexibleHeight] addSubview(view) contentView = view } 

O método loadViewFromNib() parece com o seguinte:

 private func loadViewFromNib() -> UIView? { let nibName = String(describing: type(of: self)) let nib = UINib(nibName: nibName, bundle: Bundle(for: XibView.self)) return nib.instantiate(withOwner: self, options: nil).first as? UIView } 

O método setup() deve ser chamado nos inicializadores:

 override init(frame: CGRect) { super.init(frame: frame) setup() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setup() } 

A classe XibView pronta. Exibições reutilizadas, cuja aparência é renderizada em um arquivo Xib, serão herdadas do XibView :

 final class RedView: XibView { } 


Se você agora adicionar um novo UIView ao Storyboard e definir sua classe como RedView , tudo será exibido com sucesso:

A criação de uma instância do RedView no código ocorre da maneira usual:

 let redView = RedView() 

Outro detalhe útil que nem todos podem conhecer é a capacidade de adicionar cores ao diretório .xcassets . Isso permite que você os altere globalmente em todos os Storyboards e Xibs em que são usados.

Para adicionar cores, clique em "+" no canto inferior esquerdo e selecione "Novo conjunto de cores":

Especifique o nome e a cor desejados:

A cor criada aparecerá na seção "Cores nomeadas":

Além disso, pode ser obtido no código:

 innerView.backgroundColor = UIColor(named: "BackgroundColor") 

3. Você não pode usar inicializadores personalizados para UIViewControllers criados no Storyboard


No caso do Storyboard, não podemos passar dependências nos inicializadores dos UIViewControllers . Geralmente fica assim:

 override func prepare(for segue: UIStoryboardSegue, sender: Any?) { guard segue.identifier == "detail", let detailVC = segue.destination as? DetailViewController else { return } let object = Object() detailVC.object = object } 

Esse código pode ser melhor feito usando algum tipo de constante para representar identificadores ou ferramentas como SwiftGen e R.swift , ou talvez até Perform . Mas, dessa maneira, apenas nos livramos de literais de string e adicionamos açúcar sintático, e não resolvemos os problemas que surgem:

  • Como sei como o DetailViewController configurado no exemplo acima? Se você é novo no projeto e não possui esse conhecimento, precisará abrir um arquivo com uma descrição desse controlador e estudá-lo.
  • As propriedades DetailViewController definidas após a inicialização, o que significa que elas devem ser opcionais. É necessário lidar com casos em que qualquer propriedade é nil ; caso contrário, o aplicativo pode falhar no momento mais inoportuno. É possível marcar propriedades como opcional implicitamente expandido ( var object: Object! ), Mas a essência não será alterada.
  • As propriedades devem ser marcadas como var , não let . Portanto, uma situação é possível quando alguém de fora quer mudá-las. DetailViewController deve lidar com essas situações.

Uma solução é descrita neste artigo .

4. À medida que o Storyboard cresce, a navegação se torna mais difícil


Como observamos anteriormente, você não precisa colocar tudo em um Storyboard, é melhor dividi-lo em vários menores. Com o advento da Referência do Storyboard, tornou-se muito simples.
Adicione a Referência do Storyboard da biblioteca de objetos ao Storyboard:

Definimos os valores de campo necessários no Inspetor de atributos - este é o nome do arquivo Storyboard e, se necessário, o ID referenciado , que corresponde ao ID do Storyboard da tela desejada. Por padrão, o Initial View Controller carregará:

Se você especificar um nome inválido no campo Storyboard ou se referir a um ID do Storyboard inexistente, o Xcode o alertará sobre isso no estágio de compilação.

5. Xcode fica mais lento ao carregar storyboards


Se o Storyboard contiver um grande número de telas com inúmeras restrições, o carregamento levará algum tempo. Mas, novamente, é melhor dividir o Storyboard grande em outros menores. Separadamente, eles carregam muito mais rápido e torna-se mais conveniente trabalhar com eles.

6. Os storyboards são frágeis, um bug pode causar uma falha no aplicativo em tempo de execução


Os principais pontos fracos:

  • Erros nos UICollectionViewCell UITableViewCell e UICollectionViewCell .
  • Erros nos identificadores segues.
  • Usando uma subclasse de UIView que não existe mais.
  • Sincronização de IBActions e IBOutlets com código.

Tudo isso e alguns outros problemas podem levar à falha do aplicativo em tempo de execução, o que significa que é provável que esses erros caiam na compilação do release. Por exemplo, quando definimos identificadores de células ou segues no Storyboard, eles devem ser copiados para o código onde quer que sejam usados. Ao alterar o identificador em um só lugar, ele deve ser alterado em todo o resto. Existe a possibilidade de você simplesmente esquecê-lo ou digitar um erro de digitação, mas apenas aprender sobre o erro enquanto o aplicativo estiver em execução.

Você pode reduzir a probabilidade de erros se livrando de literais de string no seu código. Para isso, os UICollectionViewCell UITableViewCell e UICollectionViewCell podem ser atribuídos aos nomes das classes de células: por exemplo, o identificador ItemTableViewCell será a string "ItemTableViewCell". No código, obtemos a célula assim:

 let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ItemTableViewCell.self)) as! ItemTableViewCell 

Você pode adicionar a função genérica correspondente ao UITableView :

 extension UITableView { open func dequeueReusableCell<T>() -> T where T: UITableViewCell { return dequeueReusableCell(withIdentifier: String(describing: T.self)) as! T } } 

E então fica mais fácil obter a célula:

 let cell: ItemTableViewCell = tableView.dequeueReusableCell() 

Se você esquecer subitamente de especificar o valor do identificador de célula no Storyboard, o Xcode exibirá um aviso, portanto você não deve ignorá-los.

Quanto aos identificadores segues, você pode usar enumerações para eles. Vamos criar um protocolo especial:

 protocol SegueHandler { associatedtype SegueIdentifier: RawRepresentable } 

UIViewController que suporta esse protocolo precisará definir um tipo aninhado com o mesmo nome. Ele lista todos os identificadores segues que este UIViewController pode processar:

 extension StartViewController: SegueHandler { enum SegueIdentifier: String { case signIn, signUp } } 

Além disso, na extensão de protocolo SegueHandler , definimos duas funções: uma aceita um UIStoryboardSegue e retorna o valor correspondente de SegueIdentifier , e a outra simplesmente chama performSegue , usando a entrada SegueIdentifier :

 extension SegueHandler where Self: UIViewController, SegueIdentifier.RawValue == String { func performSegue(withIdentifier segueIdentifier: SegueIdentifier, sender: AnyObject?) { performSegue(withIdentifier: segueIdentifier.rawValue, sender: sender) } func segueIdentifier(for segue: UIStoryboardSegue) -> SegueIdentifier { guard let identifier = segue.identifier, let identifierCase = SegueIdentifier(rawValue: identifier) else { fatalError("Invalid segue identifier \(String(describing: segue.identifier)).") } return identifierCase } } 

E agora em um UIViewController que suporta o novo protocolo, você pode trabalhar com prepare(for:sender:) seguinte maneira:

 extension StartViewController: SegueHandler { enum SegueIdentifier: String { case signIn, signUp } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { switch segueIdentifier(for: segue) { case .signIn: print("signIn") case .signUp: print("signUp") } } } 

E execute segue assim:

 performSegue(withIdentifier: .signIn, sender: nil) 

Se você adicionar um novo identificador ao SegueIdentifier , o Xcode certamente o forçará a processar no switch/case .

Outra opção para se livrar de literais de strings, como identificadores segues e outros, é usar ferramentas de geração de código como R.swift .

7. Os storyboards são menos flexíveis que o código.


Sim, isso é verdade. Se a tarefa é criar uma tela complexa com animações e efeitos que o Storyboard não pode manipular, você precisará usar o código!

8. Os storyboards não permitem alterar o tipo de UIViewControllers especiais


Por exemplo, quando você precisar alterar o tipo de UITableViewController para UICollectionViewController , é necessário excluir o objeto, adicionar um novo com outro tipo e reconfigurá-lo. Embora esse não seja um caso frequente, é importante notar que essas alterações são feitas mais rapidamente no código.

9. Os storyboards adicionam duas dependências adicionais ao projeto. Eles podem conter erros que o desenvolvedor não pode corrigir.


Este é o Interface Builder e o analisador de Storyboards. Tais casos são raros e geralmente podem ser contornados por outras soluções.

10. Revisão sofisticada do código


Lembre-se de que a revisão de código não é realmente uma pesquisa de erros. Sim, eles são encontrados no processo de visualização do código, mas o objetivo principal é identificar pontos fracos que podem criar problemas a longo prazo. Para Storyboards, este é principalmente o trabalho do Layout Automático . Não deve haver ambíguos e extraviados . Para encontrá-los, basta usar a pesquisa no XML do Storyboard para as linhas "ambíguas =" YES "" e "extraviado =" YES "" ou basta abrir o Storyboard no Interface Builder e procurar pontos vermelhos e amarelos:

No entanto, isso pode não ser suficiente. Conflitos entre restrições também podem ser detectados enquanto o aplicativo está em execução. Se uma situação semelhante ocorrer, as informações sobre isso serão exibidas no console. Como esses casos não são incomuns, sua pesquisa também deve ser levada a sério.

Todo o resto - combinando a posição e o tamanho dos elementos com o design, a ligação correta do IBOutlets e IBActions - não é para revisão de código.

Além disso, é importante fazer confirmações com mais frequência; será mais fácil para o revisor visualizar as alterações em pedaços pequenos. Ele será mais capaz de se aprofundar nos detalhes sem perder nada. Isso, por sua vez, terá um efeito positivo na qualidade da revisão do código.

Sumário


Na lista de falhas do Storyboards, deixei 4 itens (em ordem decrescente de seu valor):

  1. Os storyboards têm dificuldade em gerenciar conflitos ao mesclar alterações.
  2. Os storyboards são menos flexíveis que o código.
  3. Os storyboards são frágeis, um erro pode levar a uma falha no tempo de execução.
  4. Você não pode usar inicializadores personalizados para UIViewControllers criados no Storyboard.

Os benefícios


1. Visualização da interface do usuário e restrições


Mesmo se você é iniciante e acabou de iniciar um projeto desconhecido, pode encontrar facilmente o ponto de entrada do aplicativo e como chegar à tela desejada. Você sabe como será cada botão, rótulo ou campo de texto, qual posição eles tomarão, como as restrições os afetam, como eles interagem com outros elementos. Com apenas alguns cliques, você pode criar facilmente um novo UIView , personalizar sua aparência e comportamento. O Layout automático nos permite trabalhar com o UIView naturalmente, como se disséssemos: "Esse botão deve estar à esquerda desse rótulo e ter a mesma altura". Essa experiência na interface do usuário é intuitiva e eficaz. Você pode tentar dar exemplos em que o código bem escrito economiza mais tempo ao criar alguns elementos da interface do usuário, mas globalmente isso não muda muito. Storyboard faz seu trabalho bem.

Separadamente, observe o Layout automático. Essa é uma ferramenta muito poderosa e útil, sem a qual seria difícil criar um aplicativo que suporte todos os diferentes tamanhos de tela. O Interface Builder permite que você veja o resultado do trabalho com o Layout Automático sem iniciar o aplicativo e, se algumas restrições não se encaixarem no esquema geral, o Xcode o alertará imediatamente sobre isso. Obviamente, há casos em que o Interface Builder não é capaz de fornecer o comportamento necessário de alguma interface muito dinâmica e complexa; então, você precisa confiar no código. Mas mesmo nessas situações, você pode fazer a maior parte no Interface Builder e complementá-lo com apenas algumas linhas de código.

Vejamos alguns exemplos que demonstram os recursos úteis do Interface Builder.

Tabelas dinâmicas baseadas no UIStackView


Crie um novo UIViewController , adicione um UIScrollView em tela cheia:

No UIScrollView adicione um UIStackView vertical, encaixe-o nas bordas e defina a altura e a largura iguais ao UIScrollView . Nesta altura, atribua prioridade = Baixa (250) :

Em seguida, crie todas as células necessárias e adicione-as ao UIStackView . Talvez seja o UIView comum em uma única cópia, ou talvez o UIView , para o qual criamos nosso próprio arquivo Xib. De qualquer forma, toda a interface do usuário dessa tela está no Storyboard e, graças ao Layout automático configurado corretamente, a rolagem funcionará perfeitamente, adaptando-se ao conteúdo:



Também podemos fazer com que as células se adaptem ao tamanho do seu conteúdo. Adicione UILabel a cada célula, vincule-os às bordas:

Já está claro como tudo ficará no tempo de execução. Você pode anexar qualquer ação às células, por exemplo, alternando para outra tela. E tudo isso sem uma única linha de código.
Além disso, se você definir hidden = true para um UIView partir de um UIStackView , ele não apenas oculta, mas também não ocupa espaço. UIStackView recalcula automaticamente seus tamanhos:



Células de auto-dimensionamento


No inspetor Tamanho da tabela, defina Altura da linha = Automático e Estimativa - para algum valor médio:

Para que isso funcione, as restrições devem ser configuradas corretamente nas próprias células e permitir o cálculo preciso da altura da célula com base no conteúdo em tempo de execução. Se não estiver claro o que está em jogo, há uma explicação muito boa na documentação oficial .

Como resultado, ao iniciar o aplicativo, veremos que tudo é exibido corretamente:

Mesa de dimensionamento automático


Você precisa implementar o comportamento desta tabela:



Como conseguir uma mudança dinâmica semelhante na altura? Ao contrário de UILabel , UIButton e outras subclasses do UIView , é um pouco mais difícil fazer uma tabela, pois o Tamanho do Conteúdo Intrínseco não depende do tamanho das células dentro dela. Ela não pode calcular sua altura com base no conteúdo, mas há uma oportunidade para ajudá-la com isso.

Observe que em algum momento do vídeo a altura da tabela para de mudar, atingindo um determinado valor máximo. Isso pode ser conseguido configurando a restrição de altura da tabela com o valor Relação = Menor que ou igual :

Nesse estágio, o Interface Builder ainda não sabe qual será a altura da tabela, ele apenas conhece seu valor máximo igual a 200 (da restrição de altura). Como observado anteriormente, o Tamanho do conteúdo intrínseco não é igual ao conteúdo da tabela. No entanto, temos a capacidade de definir o espaço reservado no campo Tamanho intrínseco :

Este valor é válido apenas ao trabalhar com o Interface Builder. Obviamente, o Tamanho do Conteúdo Intrínseco não precisa ser igual a esse valor no tempo de execução. Acabamos de dizer ao Interface Builder que tudo está sob controle.

Em seguida, crie uma nova subclasse da tabela CustomTableView :

 final class CustomTableView: UITableView { override var contentSize: CGSize { didSet { invalidateIntrinsicContentSize() } } override var intrinsicContentSize: CGSize { return contentSize } } 

Um daqueles casos em que o código é necessário. Aqui chamamos invalidateIntrinsicContentSize sempre que o contentSize da tabela é alterado. Isso permitirá que o sistema aceite o novo tamanho do conteúdo intrínseco. Por sua vez, retorna contentSize , forçando a tabela a ajustar dinamicamente sua altura e exibir um certo número de células sem rolar. A rolagem aparece no momento em que atingimos o limite de restrição de altura.

Todos esses três recursos do Interface Builder podem ser combinados. Eles adicionam mais flexibilidade às opções de organização de conteúdo sem a necessidade de restrições adicionais ou qualquer UIView .

2. A capacidade de ver instantaneamente o resultado de suas ações


Se você redimensionou o UIView , o moveu alguns pontos para o lado ou alterou a cor do plano de fundo, você verá imediatamente como ele ficará no tempo de execução sem precisar iniciar o aplicativo. Não é necessário se perguntar por que algum botão não apareceu na tela ou por que o comportamento do UIView não UIView desejado.

O uso do @IBInspectable revela esse benefício ainda mais interessante. Adicione dois UILabel e duas propriedades ao RedView :

 final class RedView: XibView { @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var subtitleLabel: UILabel! @IBInspectable var title: String = "" { didSet { titleLabel.text = title } } @IBInspectable var subtitle: String = "" { didSet { subtitleLabel.text = subtitle } } } 

Dois novos campos aparecerão no Inspetor de atributos para RedView - Title e Subtitle , que marcamos como @IBInspectable :

Se tentarmos inserir valores nesses campos, veremos imediatamente como tudo ficará no tempo de execução:



Você pode controlar qualquer coisa: cornerRadius , borderWidth , borderColor . Por exemplo, estendemos a classe base UIView :

 extension UIView { @IBInspectable var cornerRadius: CGFloat { set { layer.cornerRadius = newValue } get { return layer.cornerRadius } } @IBInspectable var borderWidth: CGFloat { set { layer.borderWidth = newValue } get { return layer.borderWidth } } @IBInspectable var borderColor: UIColor? { set { layer.borderColor = newValue?.cgColor } get { return layer.borderColor != nil ? UIColor(cgColor: layer.borderColor!) : nil } } @IBInspectable var rotate: CGFloat { set { transform = CGAffineTransform(rotationAngle: newValue * .pi/180) } get { return 0 } } } 

Vimos que o Inspetor de Atributos do objeto RedView adquiriu mais 4 novos campos, com os quais você também pode jogar:



3. Visualize todos os tamanhos de tela de uma só vez


Então jogamos os elementos necessários na tela, ajustamos sua aparência e adicionamos as restrições necessárias. Como descobrimos se o conteúdo será exibido corretamente em diferentes tamanhos de tela? Obviamente, você pode executar o aplicativo em cada simulador, mas isso levará muito tempo. Existe uma opção melhor: o Xcode possui um modo de visualização, que permite ver vários tamanhos de tela ao mesmo tempo sem iniciar o aplicativo.

Chamamos o editor Assistant , nele clicamos no primeiro segmento da barra de transição, selecione Visualizar -> Configurações.storyboard (como exemplo):

Inicialmente, vemos apenas uma tela, mas podemos adicionar o necessário, clicando em "+" no canto inferior esquerdo e selecionando os dispositivos necessários na lista:

Além disso, se o Storyboard suportar vários idiomas, você poderá ver como a tela selecionada ficará com cada um deles:

O idioma pode ser selecionado para todas as telas de uma vez e para cada uma individualmente.

4. Removendo o código da UI do modelo


A criação de uma interface com o usuário sem o Interface Builder é acompanhada por uma grande quantidade de código padrão ou por superclasses e extensões que requerem trabalho de manutenção adicional. Esse código pode se infiltrar em outras partes do aplicativo, dificultando a leitura e a pesquisa. O uso de Storyboards e Xibs pode descarregar o código, tornando-o mais focado na lógica.

5. Classes de tamanho


Todos os anos, novos dispositivos aparecem, para os quais você precisa adaptar a interface do usuário. O conceito de variações de características e, em particular, classes de tamanho , que permitem criar interface do usuário para qualquer tamanho e orientação da tela, ajuda nisso.

As classes de tamanho classificam a altura (h) e a largura (w) das telas do dispositivo em termos de compactos e regulares ( C e R ). Por exemplo, o iPhone 8 possui uma classe de tamanho (wC hR) na orientação retrato e (wC hC) na paisagem, e o iPhone 8 Plus possui (wC hR) e (wR hC), respectivamente. O restante dos dispositivos pode ser encontrado aqui .

Em um Storyboard ou Xib para cada uma das classes de tamanho, você pode armazenar seu próprio conjunto de dados, e o aplicativo usará o apropriado, dependendo da orientação do dispositivo e da tela em tempo de execução, identificando a classe de tamanho atual. Se alguns parâmetros de layout forem os mesmos para todas as classes de tamanho, eles poderão ser configurados na categoria " Qualquer ", que já está selecionada por padrão.

Por exemplo, configure o tamanho da fonte, dependendo da classe de tamanho. Selecionamos o dispositivo iPhone 8 Plus para visualização de retrato no Storyboard e adicionamos uma nova condição para font: se a largura for Regular (defina o restante como "Qualquer"), o tamanho da fonte deverá ser 37:

Agora, se alterarmos a orientação da tela, o tamanho da fonte aumentará - uma nova condição funcionará, pois o iPhone 8 Plus possui uma classe de tamanho (wR hC) na orientação paisagem . No Storyboard, dependendo da classe de tamanho, você também pode ocultar Views, ativar / desativar restrições, alterar seu valor constante muito mais. Leia mais sobre como fazer tudo isso aqui .

Na captura de tela acima, vale destacar o painel inferior com a opção de dispositivo para exibir o layout. Ele permite que você verifique rapidamente a adaptabilidade da interface do usuário em qualquer dispositivo e com qualquer orientação da tela e também mostra a classe de tamanho da configuração atual (ao lado do nome do dispositivo). Entre outras coisas, à direita, existe um botão « Vary for Traits" Seu objetivo é permitir variações de características apenas para uma categoria específica de largura, altura ou largura e altura ao mesmo tempo. Por exemplo, selecionando um iPad com uma classe de tamanho (wR hR) , clique em "Variar por características" e marque a caixa ao lado de largura e altura . Agora, todas as alterações subsequentes no layout serão aplicadas apenas a dispositivos com (wR hR) até clicar em Concluído Variando .

Conclusão

#
Desvantagens
Os benefícios
1
Conflitos difíceis de governar
Visualização e restrições da interface do usuário
2
Não é tão flexível quanto o código
A capacidade de ver instantaneamente o resultado de suas ações
3
Um erro pode levar a uma falha no tempo de execução.
Visualizar todos os tamanhos de tela de uma só vez
4
Você não pode usar inicializadores personalizados para UIViewControllers
Removendo o código da interface do usuário do modelo
5
Classes de tamanho
Vimos que os Storyboards têm seus pontos fortes e fracos. Minha opinião é que você não deve se recusar completamente a usá-los. Quando usados ​​corretamente, trazem grandes benefícios e ajudam a resolver tarefas com eficácia. Você só precisa aprender como priorizar e esquecer argumentos como "Não gosto de Storyboards" ou "Estou acostumado a fazer isso".

Source: https://habr.com/ru/post/pt456086/


All Articles