Desenvolvimento

24 nov, 2015

Todo View Controller deveria ter um delegate

Publicidade

Qualquer pessoa que frequente o slack do iOS Dev BR sabe que ando insatisfeito com Storyboard. Os motivos são vários, mas hoje vou falar apenas de um, as segues.

As segues facilitam a visualização do fluxo do app para uma pessoa que não está habituada com o projeto. É só abrir ostoryboard (ou storyboards) e está tudo lá; todas as setinhas ligando seus controllers. Aí você me pergunta: “Mas isso é lindo, porque te incomoda?”.

[self performSegueWithIdentifier:@"Segue" sender:result];

O que você acha desse trecho de código? Não me refiro a aquela bela string mágica, mas onde essa linha normalmente fica. Essa instrução está contida no controller A e é executada quando o mesmo terminou seu propósito e o controller B deve ser instanciado para continuar o fluxo.

Isso implica que o controller A tem algum conhecimento do que deve acontecer depois dele. Se eu quiser trocar o controller B por um controller C, eu poderia manter o nome da segue e fazer a alteração somente no storyboard isso seria deselegante, mas não um problema. Agora imagine que o controller a ser instanciado a seguir depende de algum resultado anterior a segue. Quem deve decidir qual segue deve ser chamada? Na grande maioria dos códigos que vi (talvez não sejam tantos assim) o próprio controller A é responsável por tomar essa decisão. Isso não me cheira bem (vulgo code smell), mas vamos continuar…

Bom, em qualquer app, alguma hora você vai precisar passar informação entre os controllers e como você faz isso?

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([segue.identifier isEqualToString:@"SegueB"]) {
        MYControllerB *controller = segue.destinationViewController;
        controller.propertyB = sender;
    } else if ([segue.identifier isEqualToString:@"SegueC"]) {
        MYControllerC *controller = segue.destinationViewController;
        controller.propertyC = sender;
    }
}

Se antes não estava cheirando bem, agora o cheiro está pior que o Rio Pinheiros, em São Paulo! As strings mágicas continuam por aí, temos um sender que pode ser qualquer coisa e, por último (mas o pior de todos), o controller A sabe sobre o controller B e controller C. Mas porque isso é tão ruim?”, me perguntam. É porque esse acoplamento dificulta muito a substituição de qualquer uma das três classes, faz com que seja muito mais complexo para testar essa classe, pois não há como fazer injeção de dependência (Dependency Injection) e esse if else if … interminável é deselegante (sim, eu sei que em Swift seria um switch menos deselegante).

Depois de todo esse discurso anti-Storyboard, imagino os defensores dessa “tecnologia” estejam incomodados. Para eles, tenho duas coisas a dizer: primeiro eu acredito que existem situações em que Storyboards são adequados (projetos grandes e que vão durar muitos anos não se enquadram nessas situações – estou disposto a discutir esse assunto em uma outra ocasião); Segundo, fazendo a transição de controllers sem segue, vemos o mesmo acoplamento:

if (something) {
    MYControllerB *controller = [[MYControllerB alloc] initWithThing:aThing];
    [self.navigationController pushViewController:controller animated:YES];
} else if (otherthing) {
    MYControllerC *controller = [[MYControllerC alloc] initWithThing:aThing2];
    [self presentViewController:controller animated:YES completion:nil];
}

Nesse caso, algo ruim que era resolvido pelo segue acontece, o controller A tem a responsabilidade de escolher como os outros controllers serão apresentados e ainda esperar que ele próprio esteja dentro de um UINavigationController. Nada bom!

Existe um conceito chamado de Princípio de responsabilidade única (não sei se essa seria a tradução mais adequada, Single responsibility principle). Ele diz cada classe deve ter apenas uma responsabilidade ou, como diria o Agent Smith, um propósito. Imaginemos que o controller A tenha o propósito de obter a idade do usuário; então, dele deve ser criado quando o app precisar obter essa informação e a única coisa que o controller precisa fazer é obter essa informação. Não faz parte do propósito dele ter conhecimento (importar) classes que não tenham relação direta com seu propósito, em particular, classes que venham antes ou depois dele no fluxo. Muito menos decidir, com base nessa informação, qual seria o próximo passo no fluxo do app, instanciar o próximo controller e passar a informação para ele, decidir como esse controller será apresentado e apresentá-lo.

Esse é um problema que vem me incomodando faz algum tempo. Há um ano li um artigo falando sobre Flow Controllers e achei interessante. Ele resolve o problema da injeção de dependência, mas o controller ainda tem a responsabilidade de dizer qual é o próximo passo no fluxo do app e eu acredito que isso não faz parte do propósito dele.

Uma possível solução para isso é postular que:

  • Todo view controller tem que ter um delegate;
  • Um view controller não deve usar referências a parentViewController, navigationController,tabBarController, splitViewControoler, ou presentingViewController ou qualquer outro parent controllerque inventarem;
  • Um view controller só pode fazer (#include) de outros view controllers se esses forem necessários para cumprir seu propósito;
  • Quando um view controller completar seu propósito, ele notifica seu delegate e esse é responsável por continuar o fluxo;
  • Um view controller nunca deve usar segues, pois isso não faz parte do seu propósito.

Uma maneira de satisfazer essas condições é ter uma classe que é delegate de todos os view controllers, que sabe instanciar todos os view controllers e, inclusive, quais modelos são necessários para isso. Isso não me cheira muito bem, mas é melhor que antes. Como ainda é uma das primeiras interações, alguma hora deve aparecer alguma ideia (aceito sugestões).

Penso que esse delegate deve ser o primeiro controller do app, por exemplo, uma subclasse do UINavigationController. Essa abordagem tem algumas vantagens:

  • Pode ser uma maneira de começar a migrar um app para Swift pois, normalmente, esse controller inicial é padrão e não tem muita interação com outros controllers. Além disso, os novos controllers irão interagir somente com esse, além dos modelos e a camada de rede;
  • Todo o fluxo do app fica em apenas uma classe e não espalhado por vários lugares;
  • Como o delegate sabe instanciar todos os controllers, ele pode receber o roteamento vindo de deep links ou NSUserActivity sem dificuldade;
  • Se você quiser fazer uma classe especial para mostrar notificações ou popups personalizados, o delegate seria o cara ideal para gerenciar quando e como eles devem ser apresentados.

Um exemplo pode deixar as coisas mais claras. Um app tem dois view controllers. O responsável pelo primeira tela (DTRootViewController) em Objective-c, o propósito dele é obter do usuário um texto, sua interface seria:

@interface DTRootViewController : UIViewController

@property (nonatomic, weak) id<DTRootViewControllerDelegate> delegate;

@end

@protocol DTRootViewControllerDelegate <ViewControllerDelegate>

- (void)didSelectedText:(nullable NSString *)text
   onRootViewController:(nonnull DTRootViewController *)controller;

@end

O segundo view controller, em Swift, (OtherViewController) tem como propósito mostrar um texto, e sua interface pública seria:

protocol OtherViewControllerDelegate : ViewControllerDelegate 
{
    func shouldDismissOtherViewController(controller: OtherViewController)
}

class OtherViewController : UIViewController
{
    init(text: NSString, navigationCloseButton: Bool, delegate: OtherViewControllerDelegate?)
}

O view controller primário desse app (NavigationController) é uma subclasse do UINavigationController (também em Swift) e sua implementação seria:

class NavigationController: UINavigationController, DTRootViewControllerDelegate, OtherViewControllerDelegate
{
    override func awakeFromNib()
    {
        let controller = DTRootViewController()
        controller.delegate = self
        self.setViewControllers([controller], animated: false)
    }

    // MARK: DTRootViewController

    func didSelectedText(text: String?, onRootViewController controller: DTRootViewController)
    {
        if let text = text {
            self.presentOtherViewControllerWithText(text)
        } else {
            print("do nothing")
        }
    }

    // MARK: OtherViewController

    func presentOtherViewControllerWithText(text: String)
    {
        if text.localizedCaseInsensitiveContainsString("modal") {
            let controller = OtherViewController(text: text, navigationCloseButton: true, delegate: self)
            self.presentViewControllerWithNavigationController(controller, animated: true)
        } else if text.localizedCaseInsensitiveContainsString("push") {
            let controller = OtherViewController(text: text, navigationCloseButton: false, delegate: nil)
            self.pushViewController(controller, animated: true)
        }
    }

    func shouldDismissOtherViewController(controller: OtherViewController)
    {
        self.dismissViewControllerAnimated(true, completion: nil)
    }

    // MARK:
    func presentViewControllerWithNavigationController(controller: UIViewController,
                                                         animated: Bool)
    {
        let navigation = UINavigationController(rootViewController: controller)
        self.presentViewController(navigation, animated: animated, completion: nil)
    }
}

O projeto completo se encontra no github, mas olhando apenas a implementação do NavigationController vemos que toda a lógica de fluxo de app está contida em apenas uma classe. Dessa forma, é possível apresentar o OtherViewController tanto modalmente, quanto dentro do navigation controller, de maneira transparente, sem ter que alterar o controller apresentado.

Como eu disse anteriormente, eu ainda estou começando a utilizar essa abordagem e novos problemas e dificuldades devem aparecer com o uso. Minha intenção e fazer outros artigos sobre esse assunto conforme eu for desenvolvendo o tema, críticas, comentários e sugestões são bem vindos, o Twitter e o slack do iOS Dev BR são os canais mais fáceis.

***

Update 01: O Igor levantou um ponto que eu não tinha pensado: é possível se livrar sem grande dificuldade do acoplamento no prepareForSegue:sender: usando uma subclasse da UIStoryboardSegue, fazendo o acoplamento do controller A com o controller B dentro dessa classe. Acho uma solução bem razoável.

Update 02: O Fabri comentou que existem várias iniciativas como o Natalie para resolver o problema das strings mágicas. Acho válido, mas preferia que houvesse alguma coisa nativa.