Vamos falar de Xcode

Postado por Tales Pinheiro em 10/03/2016

Tales Pinheiro (@talesp) é mestre em computação pelo IME-USP, trabalhou por 8 anos com backend bancário programando principalmente em C, quando em 2007 (antes do anúncio do primeiro iPhone!) resolveu aprender Objective-C, e tem escrito Swift há pouco mais de 6 meses.

Vamos falar de Xcode

Algum tempo atrás apresentei no CocoaHeads SP a palestra “Design de Arcabouços: definindo uma arquitetura de fluxo de trabalho para o desenvolvimento ágil de múltiplos projetos”. Nela falei um pouco sobre como usar o Xcode (e algumas técnicas/ferramentas adicionais) de forma eficiente para projetos complexos, mais ou menos o que o Hector Zarate, engenheiro do Spotify, apresentou na palestra iOS at Spotify: From Plan to Done que assisti quando fui na UIKonf - apesar da palestra dele ser menos técnica.

Mas o assunto é extenso, e o Xcode é uma ferramenta complexa e complicada, então resolvi estender e destrinchar um pouco mais o assunto. Vou falar então de forma um pouco mais detalhada sobre os tópicos a seguir:

  • Projetos
  • Workspaces
  • Build configurations
  • Targets
  • Build Phases
  • Schemes

Projetos

Em geral, a primeira coisa que fazemos quando vamos iniciar um novo aplicativo é criar um novo projeto. É ele que contém os arquivos com código fonte, esquemas de construção dos targets, configurações de compilação, entre outras coisas.

Ao criar um novo projeto no Xcode, é gerado um “arquivo bundle” no diretório raiz do projeto. Esse “arquivo”, que na verdade é um diretório especial que o Finder exibe como arquivo, tem extensão xcodeproj. Ao navegarmos dentro desse diretório especial, vemos a seguinte estrutura de diretórios (extraído daqui):

 /(root)/
   /(project-name).xcodeproj/
     project.pbxproj
     /project.xcworkspace/
       contents.xcworkspacedata
       /xcuserdata/
         /(your name)/xcuserdatad/
           UserInterfaceState.xcuserstate
     /xcshareddata/
       /xcschemes/
         (shared scheme name).xcscheme
     /xcuserdata/
       /(your name)/xcuserdatad/
         (private scheme).xcscheme
         xcschememanagement.plist

Vemos então que na raiz desse diretório temos o arquivo project.pbxproj, e os diretórios project.xcworkspace, xcshareddata e xcuserdata.

O arquivo project.pbxproj nada mais é que um arquivo JSON. Nele temos o nome/caminho dos arquivos de código fonte (headers e arquivos de implementação), assets do projeto, frameworks usados pelo aplicativo, estrutura hierárquica de grupos de arquivos dentro do projeto (a forma como você organiza os arquivos dentro do projeto), as configurações das build phases, configurações de targets, entre outras coisas. Basicamente tudo que você vê ao selecionar o projeto dentro do Project Navigator (exibido rapidamente com o atalho ⌘1. Apesar de um pouco estranho, é relativamente fácil de entender esse arquivo, pois o próprio Xcode adiciona comentários descrevendo cada parte, como por exemplo:

// !$*UTF8*$!
{
  archiveVersion = 1;
  classes = {
  };
  objectVersion = 46;
  objects = {

/* Begin PBXBuildFile section */
    02CEB9D31B6E8EDB00488E8F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CEB9D21B6E8EDB00488E8F /* AppDelegate.swift */; };
    02CEB9D51B6E8EDB00488E8F /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CEB9D41B6E8EDB00488E8F /* ViewController.swift */; };
    02CEB9D81B6E8EDB00488E8F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 02CEB9D61B6E8EDB00488E8F /* Main.storyboard */; };
    02CEB9DA1B6E8EDB00488E8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 02CEB9D91B6E8EDB00488E8F /* Assets.xcassets */; };
    02CEB9DD1B6E8EDB00488E8F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 02CEB9DB1B6E8EDB00488E8F /* LaunchScreen.storyboard */; };
    02CEB9E81B6E8EDB00488E8F /* CatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CEB9E71B6E8EDB00488E8F /* CatsTests.swift */; };
    02CEB9F31B6E8EDB00488E8F /* CatsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CEB9F21B6E8EDB00488E8F /* CatsUITests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
    02CEB9E41B6E8EDB00488E8F /* PBXContainerItemProxy */ = {
      isa = PBXContainerItemProxy;
      containerPortal = 02CEB9C71B6E8EDB00488E8F /* Project object */;
      proxyType = 1;
      remoteGlobalIDString = 02CEB9CE1B6E8EDB00488E8F;
      remoteInfo = Cats;
    };
    02CEB9EF1B6E8EDB00488E8F /* PBXContainerItemProxy */ = {
      isa = PBXContainerItemProxy;
      containerPortal = 02CEB9C71B6E8EDB00488E8F /* Project object */;
      proxyType = 1;
      remoteGlobalIDString = 02CEB9CE1B6E8EDB00488E8F;
      remoteInfo = Cats;
    };
/* End PBXContainerItemProxy section */

Como podemos ver, a maior parte do arquivo é tomada pela chave objects, que nada mais é do que um dicionário, com uma chave hexadecimal - usada para referenciar os objetos do projeto de outras partes - e um valor, que em minhas pesquisas indicam ser sempre um outro dicionário. Para facilitar o entendimento, o Xcode adiciona ainda comentário internos, como por exemplo, /* AppDelegate.swift in Sources */. Em geral não vamos editar esse arquivo na mão, mas entendê-lo pode facilitar bastante na hora de resolver um conflito de merge com o uso de sistemas de versionamento de arquivos como git.

Vamos voltar ao Xcode e a estrutura de diretórios. Podemos ver que mesmo ao criar um projeto simples (por exemplo, via File->New Project), temos dentro da pasta do projeto uma subpasta chamada project.xcworkspace. A seguir, descrevo os workspaces um pouco melhor.

Workspaces

Workspaces mantêm a referência para projetos/subprojetos, save states (janelas e abas abertas, breakpoints do usuário, etc), localização do produto (app, biblioteca estática ou dinâmica, binários temporários) e o índice de símbolos (classes, métodos, funções, testes, etc), agrupando isso de forma a facilitar a organização de projetos inter-relacionados.

E como é o workspace que registra o índice de símbolos, indexando todos os projetos dentro dele, code completion, Jump to definition e outras funcionalidades sensíveis ao conteúdo funcionam de forma transparente entre os projetos. Então se você adiciona subprojetos, navegar entre eles é bastante facilitado. Com isso, até mesmo refatorar código é feito entre vários projetos de um mesmo workspace.

Por padrão, todos os projetos dentro de um mesmo workspace são construídos no mesmo diretório, chamado workspace build directory, e cada worskpace tem seu próprio diretório. E como todos os projetos são construídos nesse diretório, esses arquivos construídos são visíveis pelos projetos incluídos no workspace. O Xcode indexa esse diretório e tenta descobrir dependências implícitas. Assim, se um projeto de um aplicativo constrói também uma biblioteca como dependência direta, e esta tem um link para uma outra biblioteca - sendo que esta não faz parte do workspace - o Xcode consegue identificar a dependência implícita e compila também essa bilbioteca.

Mesmo que você não crie - explicitamente ou através do cocoapods, por exemplo - um workspace, é gerado um automaticamente e implicitamente para você dentro do diretório do projeto. O diretório project.xcworkspace é outro arquivo bundle, e ao analisar seu conteúdo, vemos o arquivo XML “contents.xcworkspacedata” e o arquivo “/(your name)/xcuserdatad/UserInterfaceState.xcuserstate”, que efetivamente contém os breakpoints, arquivos abertos, etc. O conteúdo do arquivo contents.xcworkspace é bastante simples, conforme podemos ver abaixo:

<?xml version="1.0" encoding="UTF-8"?>
<Workspace
   version = "1.0">
   <FileRef
      location = "group:MyApp/MyApp.xcodeproj">
   </FileRef>
   <FileRef
      location = "group:Pods/Pods.xcodeproj">
   </FileRef>
</Workspace>

Quando usamos o CocoaPods para gerenciamento de dependências, um workspace é criado, e nele é adicionado o seu projeto original, além de um novo projeto exclusivo para gerenciar as dependências.

Targets

Targets especificam produtos a serem construídos (bibliotecas, apps, etc), e suas divisões mais “tradicionais” são General, Info, Build Settings, Build Phases (além das mais recentes Capabilities, Resource Tags e da pouco usada Build Rules). Um target especifica um produto, organizando um conjunto de arquivos de entrada, um conjunto de regras e ações a serem tomadas sobre esses arquivos a fim de gerar um produto - um aplicativo ou biblioteca, por exemplo - como saída. Um projeto pode conter mais de um target, podendo gerar (tomando as devidas precauções, como não usar APIs indisponíveis) por exemplo, uma biblioteca compilada para iOS e tvOS, compartilhando código do mesmo projeto em diferentes targets.

As configurações gerais de como gerar o produto estão disponíveis na aba Build Settings, e é organizada de forma hierárquica, podendo inclusive ser compartilhada entre projetos não relacionados (nem mesmo estando no mesmo workspace, através do uso de arquivos de build configurations. A figura abaixo mostra a visão combinada das Build Settings.

Combined Build Settings

Mas recentemente tive um problema com o CocoaPods, e para facilitar a identificação de onde especificamente estava o problema, utilizei a visão categorizada, disponível no botão Levels, que pode ser exibida abaixo.

Nesse exemplo podemos ver, da direita (nível hierárquico mais básico) para a esquerda (configuração final aceita como Build Setting a ser utilizada) os níveis iOS Default, CocoaHeads (primeiro as configurações do projeto, sendo aqui o nome do seu projeto), CocoaHeads (aqui as configurações do target) e Resolved (opção exibida quando Combined está selecionado). Quando o projeto usa CocoaPods (ou quando você adiciona manualmente), são adicionados arquivos de configuração, que adicionam um nível adicional, chamado Config File. Foi nesse item que encontrei o causador do meu problema, qual era o arquivo que o estava causando, me permitindo abrir a issue no repositório do CocoaPods.

Build Settings

Como vimos acima, uma das abas to Target Editor é a Build Settings, que lista todas essas configurações disponíveis, mas estudando um pouco mais, podemos ver que cada build setting é na verdade uma variável que contém uma informação ou configuração específica sobre o processo de construção do produto, como para quais arquiteturas o binário será compilado. Como vimos acima, podemos especificar a configuração no projeto ou sobrescrever para um target específico.

Para descobrir qual o nome da variável, basta selecionar a build setting desejada e abrir a aba Quick Help do Attribute Inspector (por exemplo, pressionando ⌥⌘2), como pode ser visto abaixo (onde a opção SUPPORTED_PLATFORMS está selecionada).

Para uma lista das variáveis, valores disponíveis e valores padrão, consulte a página Build Setting Reference. Além disso, é possível criar configurações definidas por você, que podem ser usadas por sistemas de integração contínua, compilação via linha de comando e, em alguns casos, como macros de preprocessamento dentro do código (ou dentro de um arquivo de build configuration). Também é útil utilizar configurações condicionais, permitindo por exemplo linkar com versões diferentes de uma biblioteca caso esteja sendo construído para o dispositivo ou para o simulador: já precisei usar isso quando me forneceram duas biliotecas, uma compilada apenas para arquitetura i386 (usada pelo simulador em processadores 32 bits. Macs mais novos usam arquitetura x86_64) e outro compilada para armv6 e armv7.

Uma referência legal de como utilizar é a página Xcode Build Settings Part 1: Preprocessing

Build Phases

As build phases são um pequeno conjunto de fases necessárias para a geração do seu produto. As mais comuns são:

  • Target Dependencies: Depêndencias explícitas de outras bibliotecas. Caso seu projeto dependa de uma biblioteca não compilada por um projeto dentro da sua workspace (explícita ou a implícita, incluída dentro do bundle xcodeproj), você adiciona aqui a dependência.
  • Compile Sources: A lista de todos os arquivos de código fonte compilados para gerar seu produto. Podemos passar flags de compilação (a lista depende do compilador, mas para o clang a lista completa está aqui). Durante a migração de projetos que não usavam Automatic Reference Couting era comum ir migrando o projeto aos poucos, e marcar arquivo por arquivo quais usavam ARC ou não. Além de ser possível tratar individualmente por arquivo, é possível tratar de forma global via Build Setting. Quando trabalho por mais tempo em um produto, gosto de ligar por exemplo as opções -Weverything e para a geração do binário final, -Werror. Assim tenho uma análise minuciosa do compilador, e todos os warnings são tratados como erro. Infelizmente em projetos mais curtos não é possível ter tanta garantia, pois a validação disso às vezes exige um tempo maior.
  • Link binary with Libraries: Caso alguém (ou alguma empresa) tenha lhe fornecido uma bilioteca pré-compilada (por exemplo, o SDK da Hockey ou Fabric), ela estará listada aqui. Normalmente a integração é feita de forma automática por gerenciadores de dependência, mas pode ser necessário adicionar manualmente aqui.
  • Copy bundle resources: arquivos de storyboard, assets como imagens, vídeos, e arquivos de áudio, por exemplo, são listados aqui.

Além dessas mais comuns e incluídas por padrão, pode ser útil - ou mesmo necessário - incluir uma (ou mais) através da opção New run script phase. Por exemplo, ferramentas de geração de código como Natalie ou mogenerator podem ter scripts para analisar, respectivamente, os arquivos de storyboard ou Core Data, e ferramentas de lint, como OCLint ou SwiftLint podem fazer análises do código e identificar coisas fora do padrão do time ou code smells, sendo comum nesse caso adicionar scripts no começo do processo, e ferramentas como HockeyApp ou Fabric podem pedir para adicionar um script no final, habilitando a nova versão no serviço deles. A imagem abaixo mostra como adicionar uma nova run script phase:

Build configurations

Build Configurations são conjuntos de build settings, a serem usadas por uma determinada compilação, especificada por um scheme. Por padrão, temos sempre duas configurações dessas: Debug e Release. É através dela que podemos, por exemplo, configurar para que sob certas condições, um build setting específico seja usado. Por exemplo, podemos duplicar a build configuration Release como exibido na imagem abaixo, para ter configurações de construção do produto diferente para, digamos, um ambiente corporativo. (a imagem abaixo foi tirada do projeto ribotTeamiOS-tvOS, que demonstra o compartilhamento de código de forma simples entre iOS e tvOS)

Podemos, com a ajuda da técnica exposta no artigo da Thoughtbot, gerar binários diferentes com Bundle ID diferentes para o ambiente empresarial usando distribuição Enterprise, por exemplo. As facilidades que isso nos dá são muitas!

Schemes

Por último gostaria de falar um pouco sobre Schemes, que são um conjunto de ações que definem quais targets devem ser construídos, qual conjunto de configurações devem ser usadas para cada tipo de compilação (execução no simulador/dispositivo, teste, profile, para análise estática e arquivamento para distribuição.

Você pode ter múltiplos esquemas, mas apenas um está ativo de cada vez. Por padrão os schemes são armazenados no projeto, mas caso você deseje por exemplo usar o Xcode Server como servidor de integração contínua através do uso de Xcode Bots, o scheme deve ser compartilhado via workspace. A figura abaixo exibe as opções normalmente disponíveis na aba Build, indicando qual target deve ser construído para cada tipo de execução. Como podemos ver, não faz sentido compilar o bundle de testes para a compilação para distribuição - archive. Podemos ver também que esse esquema não foi compartilhado, o que impede seu uso pelos Bots do Xcode.

Na aba Run temos a chance de configurar a forma como o app é executado - no simulador ou no device - e podemos passar “parâmetros de linha de comando” para a execução do app. Já usei isso para habilitar/desabilitar mensagens no console, em um app onde o requisito era não logar nada, mas para desenvolvimento ligávamos as mensagens de debug - inclusive controlando níveis de mensagens de erro (info, warning, erro, etc) de acordo com o parâmentro. Assim não corríamos a chance de acidentalmente enviar o app para produção exibindo os logs. Essa não é a única forma que isso pode ser feito, mas foi a forma que usamos, e isso pode ter outras utilidades para você :D

Outra funcionalidade da aba Run que já foi muito mais usada no passado - na era pre-ARC - foi habilitar a opção Enable Zombie Objects na aba Diagnostics, o que permitia detectar e corrigir vários bugs de gerenciamento de memória. Você pode ver um pouco mais aqui, mas não vejo mais tanta utilidade no dia a dia. A imagem abaixo exibe essa aba e mais algumas opções.

Conclusão

Não quis de forma nenhuma descrever todas essas funcionalidades e conceitos de forma exaustiva, até porque ainda teria MUITO a escrever. Mas acho importante conhecer esses conceitos, nos ajudam na hora de configurar como o app é construído/arquitetado, como é compilado, nos permitem pensar em novas formas de organizar nossos projetos.

Espero que tenham gostado, e que no futuro eu consiga escrever um outro artigo que daria uma espécie de sequência para esse artigo. Bom proveito e bons hacks na forma de construir seus apps :)