segunda-feira, 3 de janeiro de 2011

Criando apps para iPhone com Swift - Persistência


Essa é a quarta lição do curso, e vamos abordar um assunto extremamente importante: persistência de dados. Agora, que você já sabe um pouco sobre a arquitetura de apps iOS e sobre a linguagem Swift, é hora de vermos como podemos persistir dados.



As lições são postadas sempre que ficarem prontas! Se tiver dúvidas, acesse nossa página de fórum.

Acompanhe o material do curso e veja o resto das lições!



Persistência e o estado das apps




A não ser que você explicitamente pare uma app, ela ficará rodando indefinidamente, porém, existem situações, nas quais, sua app poderá ser terminada pelo iOS.



Precisamos entender qual é o ciclo de vida de uma app, ou quais são os estados e transições pelos quais ela pode passar.



Estado de uma app




O iOS, desde a versão 4, permite execução multitarefa, ou seja, podem existir apps em “Background” executando coisas. Elas podem estar fazendo “refresh” de seus dados, por exemplo.



Uma vez que esteja em execução, uma app pode estar:
  • Em Foreground (Em primeiro plano): Está visível e o usuário pode interagir com ela;
  • Em Background (Em segundo plano): Está executando, porém, nenhum elemento de UI está visível. Isso pode acontecer se o protetor de tela travar o seu aparelho (ele entrou em “descanso”), ou se você executou uma nova app.



Esses são os estados de uma app no iOS:
  • Not running” (Não está sendo executada): A app está instalada, porém, não há um só módulo dela na memória RAM, ou seja, não foi iniciada ou então foi terminada;
  • Inactive” (Inativa): É um estado temporário, no qual a app não responde mais a eventos, embora esteja visível. Ela está mudando de estado, provavelmente para Background;
  • Active” (Ativa): A app está em primeiro plano (“Foreground”) e respondendo a eventos;
  • Background” (Em segundo plano): Uma app entra em execução em Background por vários motivos:
    • O aparelho entrou em “descanso”;
    • Você iniciou uma nova app, movendo a atual para segundo plano;
    • O iOS iniciou sua app em Background, devido a algum evento, por exemplo: Notificações.
  • Suspended” (Suspensa): A app está em memória RAM, mas não está mais executando código algum. Uma vez neste estado, ela poderá ser terminada pelo iOS ou então reativada por você.




Quando uma aplicação vai para “Suspended”, ela poderá ser terminada. Neste caso, qualquer estado que você tenha guardado, será perdido. Se você tem alguma variável cujo conteúdo precisa ser salvo, deve salvar em “disco” (um smartphone não tem “disco”, mas a analogia cai bem), antes da app terminar.



Caso não o faça, a app iniciará a partir de seu estado original.



Isso pode ser o que você deseja, dependendo da funcionalidade de sua app. Por exemplo, nossa app “Conversor”, do capítulo 3, não tem nenhum dado persistente, e carrega tudo o que é necessário novamente, quando é ativada. Neste caso, diz-se que o estado da app é transiente.



Mas, pode ser o caso, de você querer salvar alguma coisa, para ser lida futuramente, quando a app voltar a ser executada. Isto se chama “persistência de estado”.



Tipos de persisência




Existem várias maneiras de persistir o estado. Dependendo do tipo de estado e do tipo de nossa app, podemos fazer várias coisas:
  • Gravar um “arquivo”;
  • Atualizar um Banco de Dados;
  • Enviar para um Web Service externo.



O tipo mais simples de estado persistente são as “preferências do usuário”. Podemos salvar alguns ajustes que o usuário fez, de modo que, quando a app abrir novamente, tudo apareça da maneira que ele ajustou. Isto melhora a experiência do usuário com nossa app.



Este tipo de informação, geralmente, tem o formato “dado / valor”, como um dicionário. Embora possamos gravar isso em um “arquivo”, ou em um Banco de Dados, é melhor escolher um mecanismo mais simples.



Nós veremos como persistir dois tipos de preferências: Internas e externas.



Já o tipo de estado relacionado ao conteúdo da app é bem mais complexo. Por exemplo, uma app que gerencie contas a pagar e a receber, deve manter um registro de datas, credores, devedores e valores. Neste caso, é melhor usar um mecanismo de persistência mais flexível, como arquivos em disco ou Banco de Dados.



Nós veremos como usar o framework “Core Data” para persistir conteúdo.



Embora você possa criar “arquivos” e Bancos de Dados sem usar o Core data, deve tomar muito cuidado. Se sua app não seguir as regras, pode ser rejeitada pela Apple. Então, é melhor usar os mecanismos que ela recomenda.



Salvando configurações




Podemos salvar e ler configurações (ou ajustes), da app de maneira bem simples. Esses ajustes podem ser associados ao usuário ou a um domínio de app. Usamos a classe NSUserDefaults para obter os valores das configurações:



let defaults = NSUserDefaults.standardUserDefaults()



O método “standardUserDefaults()” retorna um objeto NSUserDefaults que contém pares de tuplas “chave / valor”, obtidas a partir de várias fontes, incluindo preferências globais e localizadas.



Para salvar um valor nos defaults, podemos usar o método apropriado para cada tipo de valor. Eis alguns deles:
  • Valor lógico : setBool(<valor Bool>, forKey: “<nome da chave>”)
  • Número inteiro: setInteger(<número inteiro>, forKey: “<nome da chave>”)
  • Número real: setFloat / setDouble(<número real>, forKey: “<nome da chave>”)
  • Objetos: setObject(<objeto>, forKey: “<nome da chave>”)



A princípio, você só pode armazenar objetos do tipo “Property List”, como: String, NSData, NSDictionary, NSArray etc.



É possível armazenar um tipo criado por você, uma instância de uma classe, por exemplo, mas você terá que fazê-la seguir o protocolo NSCoding, e terá que usar também as classes NSKeyedArchiver e NSKeyedUnarchiver, o que pode complicar um pouco o seu código. Veremos mais adiante.



O projeto “capt5/Conversor.zip” deste capítulo, mostra essa parte de ler e salvar configurações usando o NSUserDefaults.



Lidando com configurações simples




Podemos usar os métodos de NSUserDefaults para recuperar propriedades simples. Todos os métodos retornam valores iniciais, mesmo que a propriedade não exista no default. Por exemplo:
  • boolForKey(“<chave>”): Retorna um valor lógico. Se a chave não existir, retornar false;
  • integerForKey(“<chave>”): Retorna um valor inteiro. Se a chave não existir, retorna zero;
  • floatForKey(“<chave>”): Retorna um valor real. Se a chave não existir, retorna zero;
  • stringForKey(“<chave>”): Retorna um string. Se a chave não existir, retorna nil;
  • objectForKey(“<chave>”): Retorna um objeto. Se a chave não existir, retorna nil;



Vamos ver um exemplo. Abra o projeto “Conversor.zip” e, no arquivo “FirstViewController.swift”, veja o método: “viewDidLoad()”:


  override func viewDidLoad() {
    super.viewDidLoad()
    let defaults = NSUserDefaults.standardUserDefaults()
    celsiusPref = defaults.boolForKey("celsiusPref")

Estamos recuperando um valor lógico (Bool), associado à chave “celsiusPref”. Se não existir a chave, o valor false será retornado.



Nós usamos essa preferência para modificar a ordem de carga dos “PickerViews”. Se você deixar como false, a lista de Fahreinheit será carregada e a de Celsius será convertida a partir dela. Se você modificar para true, a lista de Celsius será carregada primeiro, e a de Fahreinheit será convertida a partir dela.



Eu permiti ao usuário modificar esse valor, criando um UISwitch no ViewController. Veja o arquivo “Main.storyboard”:



Eu criei duas conexões:
  • Um “outlet”, para acessar o valor do Switch:
    • @IBOutlet weak var celsiusPrefToggle: UISwitch!
  • Uma “action”, para saber quando o Switch foi alterado pelo usuário:
    • @IBAction func trocouPreferencia(sender: UISwitch {}



No método “viewDidLoad()”, eu obtenho o valor da propriedade e carrego as PickerViews na ordem especificada. No método “trocouPreferencia()”, eu limpo e recarrego as PickerViews, e salvo o valor atual da preferência:



@IBAction func trocouPreferencia(sender: UISwitch) {
celsiusPref = sender.on
carregarPickerView()
celsiusPicker.reloadComponent(0)
fahreinheitPicker.reloadComponent(0)
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setBool(celsiusPref, forKey: "celsiusPref")
}

O método “reloadComponent()” recarrega uma PickerView, invocando a “datasource”, que, por acaso, é a nossa própria classe.



Configurações complexas




Você pode salvar diretamente Strings com o “setObject”. Vetores e Dicionários também, só que seus elementos devem ser de tipos de dados “Preference List”, como: String e NSData.



Agora, vamos supor que você tenha criado uma classe para agrupar as preferências do usuário:


class PrefUsuario  {
  var nome : String
  var modo : Int
  
  init(nome: String, modo: Int) {
    self.nome = nome
    self.modo = modo
    super.init()
  }
}

Se você quiser armazenar uma instância dessa classe nos defaults, então tem que fazê-la implementar o protocolo “NSCoding”. Este protocolo permite tratar instâncias que são serializadas em objetos NSData. Para salvar nossa instância, precisamos serializá-la. Eis a nova versão:


class PrefUsuario : NSObject, NSCoding {
  var nome : String
  var modo : Int
  
  init(nome: String, modo: Int) {
    self.nome = nome
    self.modo = modo
    super.init()
  }
  required init(coder decoder: NSCoder) {
    self.nome = decoder.decodeObjectForKey("nome") as String
    self.modo = decoder.decodeIntegerForKey("modo")
    super.init()
  }
  func encodeWithCoder(encoder: NSCoder) {
    encoder.encodeObject(self.nome, forKey: "nome")
    encoder.encodeInt(Int32(self.modo), forKey: "modo")
  }
}

Para começar, temos que colocar a superclasse (NSObject) e o protocolo (NSCoding). Depois, temos que implementar o construtor requerido pelo Protocolo NSCoding (repare o atributo “required”. Este construtor recebe um objeto NSCoder e decodifica os elementos que foram serializados, para construir uma nova instância da nossa classe. Esse construtor será usado quando estivermos recuperando uma instância do NSUserDefaults.



Quando quisermos preparar uma instância para armazenar em NSUserDefaults, o método “encodeWithCoder” será chamado. Ele recebe uma instância de NSCoder e codifica as propriedades, preparando para serializar a instância.



Eu inclui essa classe no arquivo “FirstViewController.swift”, só para demonstração. No momento em que a app for iniciada, o método “viewDidLoad()” será chamado, e eu estou obtendo o valor atual da configuração:



var prefUsuario : PrefUsuario?
var dadosBrutos : NSData? = defaults.dataForKey("prefUsuario")

Se a preferência existir, então tudo certo. Se não existir, um valor nil será retornado. Se a variável pode ser nil, eu tenho que declará-la como opcional, com a interrogação no tipo. O método “dataForKey” carrega um NSData que foi serializado.



Antes de usar o valor, preciso saber se a variávei ficou com nil, o que significa que a propriedade ainda não existe:


    if dadosBrutos == nil {
      prefUsuario = PrefUsuario(nome: "Teste", modo: 10)
      dadosBrutos = NSKeyedArchiver.archivedDataWithRootObject(prefUsuario!)
      defaults.setObject(dadosBrutos, forKey: "prefUsuario")
    }
    else {
      prefUsuario = NSKeyedUnarchiver.unarchiveObjectWithData(dadosBrutos!) as? PrefUsuario
      NSLog("Recuperada: %@", prefUsuario!.nome)
    }

Se a preferência não existir, eu tenho que criar uma instância da classe “PrefUsuario”, e salvar nos defaults. Só que eu não posso salvar meus tipos de dados! Sou obrigado a serializar a instância, transformando-a em uma instância de NSData. A classe NSKeyedArchiver faz isso. E o que acontecerá na minha instância? O método “encodeWithCoder(encoder:)” será invocado.



Assim, a preferência “prefUsuario” está salva e eu posso até desligar o aparelho.



Agora, se a preferência existir, então a variável “dadosBtutos” contém uma instância de NSData, que eu preciso de-serializar, para recuperar a instância da minha classe, PrefUsuario. A classe NSKeyedUnarchiver faz isso para mim. Node o “cast” que eu fiz, transformando o objeto retornado em uma instância de PrefUsuario:



var variavel : <tipo> = outravariavel as <tipo>



O operador “as” faz um “Downcasting”, ou seja, transforma a instância de uma classe, na instância de uma subclasse dela. No meu caso, PrefUsuario é uma subclasse de NSObject. Vamos rever o comando:



prefUsuario = NSKeyedUnarchiver.unarchiveObjectWithData(dadosBrutos!) as? PrefUsuario



Eu usei “as?” porque o método “unarchiveObjectWithData” pode retornar nil.



E podemos salvar vetores também, da mesma maneira. Poreríamos criar um vetor de PrefUsuario e salvá-lo, serializando da mesma forma.



Configurações visíveis ao usuário




Algumas configurações podem ser tornadas visíveis ao usuário, mesmo que a app não esteja sendo executada. Isso pode ser visto quando entramos na app “Ajustes” de um dispositivo iOS:

Algumas preferências podem ser visíveis para o usuário o que melhora a experiência geral com a app. Outras, simplesmente não fazem sentido. Se você quiser criar configurações que possam ser vista na app “Ajustes”, então tem que criar um iOS Settings Bundle para sua app.



Eu criei um Settings Bundle para a app “Conversor.zip” deste capítulo. Veja só a página de ajustes dessa app:



Ela contém um único ajuste: Se a lista de Celsius tem que ser criada em primeiro lugar. Agora, poderíamos até mesmo apagar o UISwitch da nossa página, pois o lugar correto para editar preferências é na app “Ajustes”.



A primeira coisa que temos que criar é um “Settings Bundle”. Você pode até usar o template do Xcode, que já facilita muito. O “Settings.Bundle” é uma pasta que fica na raiz do seu projeto iOS. Ela contém um arquivo chamado “Root.plist”, que é uma lista de propriedades. E também contém uma pasta para cada idioma que você queira adicionar.



Para criar um Settings Bundle, abra o menu “File / New”, escolha “File” e depois selecione “iOS / Resource” e, finalmente: “Settings Bundle”.



Eis uma estrutura típica de “Settings Bundle”:



drwxr-xr-x 5 cleutonsampaio staff 170 20 Mar 14:40 .
drwxr-xr-x 7 cleutonsampaio staff 238 20 Mar 14:26 ..
-rw-r--r--@ 1 cleutonsampaio staff 507 20 Mar 14:37 Root.plist
drwxr-xr-x 3 cleutonsampaio staff 102 20 Mar 14:41 en.lproj
drwxr-xr-x 3 cleutonsampaio staff 102 20 Mar 14:41 pt.lproj




Cada subpasta “.lproj” contém os strings traduzidos em um idioma. O nome de cada pasta começa com o código do país. Dentro das pastas dos idiomas, temos arquivos “.strings”. Um para cada página de configuração. Como estamos criando apenas uma página, temos um só arquivo: Root.strings. Um para cada pasta de idioma.



Página de Ajuste



Podemos ter várias páginas na app de Ajustes. Mas, para simplicidade, vou mostar apenas uma. O arquivo “Root.plist” tem as propriedades de ajuste. Eis o seu conteúdo:



<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>PreferenceSpecifiers</key>
	<array>
		<dict>
			<key>DefaultValue</key>
			<false/>
			<key>Key</key>
			<string>celsiusPref</string>
			<key>Title</key>
			<string>celsiusTitulo</string>
			<key>Type</key>
			<string>PSToggleSwitchSpecifier</string>
		</dict>
	</array>
	<key>StringsTable</key>
	<string>Root</string>
</dict>
</plist>

No editor do Xcode, aparece assim:





Quando você cria um Settings Bundle a partir do assistente, ele já coloca um monte de coisas na página “Root”. Por exemplo, ele cria um grupo (podemos agrupar os ajustes), uma Text Field, um Switch etc.



É só expandir “Preference items” e deletar o que está lá. Ou então, você aproveita o Switch. Um Settings Bundle pode vários elementos. Vamos ver alguns deles:
  • Text Field: Type = PSTextFieldSpecifier, um campo de texto para você digitar algum ajuste;
  • Toggle Switch : Type = PSToggleSwitchSpecifier, uma chave liga / desliga;
  • Slider : Type = PSSliderSpecifier, um controle deslizante para valores numéricos.



Você pode saber mais sobre os controles de uma página de ajustes em: https://developer.apple.com/library/ios/documentation/Cocoa/Conceptual/UserDefaults/Preferences/Preferences.html.



Podemos criar uma página informando no editor ou então criando diretamente o arquivo XML. Por exemplo, a página que eu criei tem um Toggle Switch:


<plist version="1.0">
<dict>
	<key>PreferenceSpecifiers</key>
	<array>
		<dict>
			<key>DefaultValue</key>
			<false/>
			<key>Key</key>
			<string>celsiusPref</string>
			<key>Title</key>
			<string>celsiusTitulo</string>
			<key>Type</key>
			<string>PSToggleSwitchSpecifier</string>
		</dict>
	</array>
	<key>StringsTable</key>
	<string>Root</string>
</dict>
</plist>

É um dicionário chave / valor. Cada tag “key” indica uma chave de configuração da página, e cada tag seguinte, o seu valor. Temos que informar as chaves:
  • DefaultValue : Se o usuário ainda não mudou essa propriedade, informa o valor inicial da configuração;
  • Key : Nome da chave de configuração, que depois vira o nome de um setting dentro do NSUserDefaults;
  • Title : O título que vai aparecer para a propriedade. Esse valor é chave para a tabela de Strings, nas pastas de internacionalização;
  • Type : O Tipo de controle que vai aparecer. Nesse caso: Toggle Switch (PSToggleSwitchSpecifier).



Com esses ajustes a página de configuração aparecerá como já mostrei anteriormente.



As tabelas de internacionalização indicam qual é o valor do String para cada chave fornecida. No nosso caso, temos dois arquivos, cada um dentro da sua pasta de idioma: “en.lproj” e “pt.lproj”:



em.lproj / Root.strings:
"celsiusTitulo" = "Celsius First";

pt.lproj / Root.strings:
"celsiusTitulo" = "Celsius Primeiro";



Como gerar um evento na app



Quando o usuário mudar uma propriedade em uma página de ajuste, a app deve ser notificada sobre isso. Adicionamos um “Observador” ao NSUserDefaults, para a notificação do tipo: “NSUserDefaultsDidChangeNotification”. Isso deve ser feito no método “viewDidLoad()”, do nosso View Controller. Eis o comando (Está no “Conversor.zip”, dentro de: “FirstViewController.swift”):



NSNotificationCenter.defaultCenter().addObserver(self, selector: "mudouPreferencia:", name: "NSUserDefaultsDidChangeNotification", object: nil)




Quando o usuário alterar a propriedade na página de ajustes, o valor de “celsiusPref”, dentro de NSUserDefaults, será alterado, e uma notificação “NSUserDefaultsDidChangeNotification” ocorrerá. Logo, o método “mudouPreferencia()” será invocado (temos que informar o nome do seletor, com dois pontos mesmo). Eis o método “mudouPreferencia()”:


  func mudouPreferencia(notification: NSNotification) {
    let defaults : NSUserDefaults? = notification.object as? NSUserDefaults
    celsiusPref = defaults!.boolForKey("celsiusPref")
    carregarPickerView()
    celsiusPicker.reloadComponent(0)
    fahreinheitPicker.reloadComponent(0)
    NSLog("Trocou preferência no Settings: \(celsiusPref)")
  }

A assinatura do método inclui um parâmetro do tipo “NSNotification”. Ele tem uma propriedade “object”, que referencia o objeto que gerou a notificação, neste caso: NSUserDefaults. Fiz um “downcast” para ele e o usei para recuperar a propriedade “celsiusPref”, alterada pelo usuário. Com isso, mudei a ordem de carga das PickerViews.



Persistindo dados em arquivos




Sendo bem sincero com você, eu não recomendo que você leia e grave arquivos diretamente no iOS. O motivo é que a Apple tem políticas muito restritivas para avaliação de apps, e pode rejeitar o seu projeto por causa disto. Nós vamos falar disso mais adiante, porém, se quiser adiantar e ler:



https://developer.apple.com/icloud/documentation/data-storage/index.html



Em especial, tem quatri itens muito relevantes:



Only documents and other data that is user-generated, or that cannot otherwise be recreated by your application, should be stored in the <Application_Home>/Documents directory and will be automatically backed up by iCloud.”



Somente documentos e outros dados criados pelo usuário, ou que não possam ser recriados pela app, devem ser gravados no diretório: <Application_Home>/Documents, e serão automaticamente “backupeados” pelo iCloud.



Data that can be downloaded again or regenerated should be stored in the <Application_Home>/Library/Caches directory. Examples of files you should put in the Caches directory include database cache files and downloadable content, such as that used by magazine, newspaper, and map applications.”



Dados que possam ser baixados ou regerados novamente, devem ser armazenados no diretório: <Application_Home>/Library/Caches. Entre os exemplos de arquivos que você guardaria no diretório Caches incluem: Bancos de dados e arquivos de conteúdo baixado (DLC), utilizados por apps de revistas e mapas.


Data that is used only temporarily should be stored in the <Application_Home>/tmp directory. Although these files are not backed up to iCloud, remember to delete those files when you are done with them so that they do not continue to consume space on the user’s device.”


Dados que sejam utilizados temporariamente, devem ser guardados no diretório <Application_Home>/tmp. Embora estes arquivos não sejam “backupeados” para o iCloud, você deve se lembrar de deletá-los, quando não forem mais necessário, evitando que consumam espaço no dispositivo do usuário.


Use the "do not back up" attribute for specifying files that should remain on device, even in low storage situations. Use this attribute with data that can be recreated but needs to persist even in low storage situations for proper functioning of your app or because customers expect it to be available during offline use. This attribute works on marked files regardless of what directory they are in, including the Documents directory. These files will not be purged and will not be included in the user's iCloud or iTunes backup. Because these files do use on-device storage space, your app is responsible for monitoring and purging these files periodically.”



Use o atributo “do not back up” para especificar arquivos que devam permanecer no dispositivo, mesmo em situação de pouca memória. Use esse atributo com dados que possam ser recriados, mas que sejam persistentes mesmo quando o sistema está com pouca memória, para o correto funcionamento da sua app, ou porque os usuários esperem que estes arquivos estejam disponíveis para uso offline (quando não estejam conectados). Este atributo funciona nos arquivos marcados, independentemente de qual diretório estejam, incluindo o diretório Documents. Estes arquivos não serão deletados e não serão incluídos no backup para o iCloud ou iTunes. Como esses arquivos usam memória do dispositivo, sua app deve ser responsável por monitorá-los e apagá-los periodicamente.



Já ouvi muitos casos de desenvolvedores que tiveram apps rejeitadas pelo uso impróprio do armazenamento do dispositivo.



Isto posto, vamos ver como ler e gravar arquivos no iOS 8.



Maneira simples




Se você deseja simplesmente gravar um string, a maneira mais simples de gravar é com os métodos de instância e classe de String. Digite esse exemplo em um Playground:


let fs = NSFileManager.defaultManager()
let diretorios : [String] = NSSearchPathForDirectoriesInDomains( .DocumentDirectory,
  .UserDomainMask, true) as [String]
let caminho = diretorios[0].stringByAppendingPathComponent("arquivo.txt")

let texto = "Este é o conteúdo do arquivo"

var erro: NSError?

if !fs.fileExistsAtPath(caminho) {
    texto.writeToFile(caminho, atomically: false, encoding: NSUTF8StringEncoding, error: &erro)
  
  if erro != nil {
    println("Erro ao gravar: \(erro!.localizedDescription)")
  }
}


var leitura = String(contentsOfFile: caminho, encoding:NSUTF8StringEncoding, error: &erro)

if erro != nil {
  println("Erro ao ler: \(erro!.localizedDescription)")
}

Ele usa a classe NSFileManager para obter o caminho do diretório Documents, e monta o caminho a ser usado acrescentando o File Path. O método “stringByAppendingPathComponent” faz isso.



Depois, ele testa se o arquivo existe e, caso não exista, cria o arquivo gravando um String. O método “writeToFile” faz isto. Ele possui os parâmetros:
  • Caminho: Caminho onde você quer gravar o arquivo;
  • encoding: Codificação dos caracteres. Eu recomendo usar sempre UTF-8 (“NSUTF8StringEncoding”);
  • error: Ponteiro para uma variável do tipo NSError.



Isso serve para coisas muito simples.



Maneira mais flexível




Se você quer gravar uma estrutura no disco, como um arquivo contendo vários registros, formados por objetos, a melhor opção é gerenciar vetores em memória, convertendo-os para NSData ao gravar.



A classe NSFileManager tem dois métodos para isso:
  • “createFileAtPath” : Permite criar um arquivo cujos dados são fornecidos por uma instância de NSData;
  • “contentsAtPath” : Lê o conteúdo de um arquivo para uma instância de NSData.



Lembra-se da classe “PrefUsuario” que criamos? Bem, ela está dentro de um arquivo chamado “capt5/exemploArquivo.txt”. Se quiser ver um bom exemplo, copie o conteúdo desse arquivo e cole em um Playground. Eis a classe:


class PrefUsuario : NSObject, NSCoding {
  var nome : String
  var modo : Int
  
  init(nome: String, modo: Int) {
    self.nome = nome
    self.modo = modo
    super.init()
  }
  required init(coder decoder: NSCoder) {
    self.nome = decoder.decodeObjectForKey("nome") as String
    self.modo = decoder.decodeIntegerForKey("modo")
    super.init()
  }
  func encodeWithCoder(encoder: NSCoder) {
    encoder.encodeObject(self.nome, forKey: "nome")
    encoder.encodeInt(Int32(self.modo), forKey: "modo")
  }
}


Neste exemplo, vou criar algumas instâncias e um vetor para armazená-las:

var prefs : [PrefUsuario] = []

var p1 = PrefUsuario(nome: "primeiro", modo: 1)
var p2 = PrefUsuario(nome: "segundo", modo: 15)

prefs.append(p1)
prefs.append(p2)




Agora, para salvar em um arquivo, eu preciso converter em uma instância de NSData, usando a classe NSKeyedArchiver, que usamos anteriormente para gravar em no NSUserDefaults:



var dadosBrutos : NSData = NSKeyedArchiver.archivedDataWithRootObject(prefs)

Pronto! Meu vetor e todos os objetos contidos nele, foram convertidos em um “Stream” de bytes. Agora, posso criar um arquivo:



let fs = NSFileManager.defaultManager()
let diretorios : [String] = NSSearchPathForDirectoriesInDomains( .DocumentDirectory,
.UserDomainMask, true) as [String]
let caminho = diretorios[0].stringByAppendingPathComponent("vetorprefs.txt")

if fs.createFileAtPath(caminho, contents: dadosBrutos, attributes: nil) {
println("Criou")
}



O método “createFileAtPath” retorna true, se for bem sucedido. Para ler o arquivo, eu uso o método “contentAtPath”:



var dadosLidos : NSData? = fs.contentsAtPath(caminho)
prefs.removeAll(keepCapacity: false)




Altes de converter a instância de NSData lida em vetor, eu o limpei, para ter certeza que vou recarregá-lo. Agora, eu uso o NSKeyedUnarchiver para transformar meus dados em um vetor de PrefUsuario:



prefs = NSKeyedUnarchiver.unarchiveObjectWithData(dadosLidos!) as[PrefUsuario]!



E, para testar, eu mostro o conteúdo do “nome” da segunda preferência:



println(prefs[1].nome)



Para gerenciar seus arquivos, a classe NSFileManager ainda tem alguns métodos interessantes:
  • “createDirectoryAtPath” : Cria um diretório abaixo de outro;
  • “removeItemAtPath” : Remove um arquivo ou diretório.



A classe tem muito mais métodos, porém, esses são os mais básicos. Se quiser saber mais, veja a referência: https://developer.apple.com/library/ios//documentation/Cocoa/Reference/Foundation/Classes/NSFileManager_Class/index.html



Atenção ao salvar seus arquivos




Como já dissemos, a Apple é muito rigorosa na avaliação de apps. E um dos critérios que geram muita rejeição é falta de aderência ao padrão de armazenamento de dados. Especialmente com o iOS 8, que salva tudo no iCloud.



É importante entender claramente como é a estrutura de armazenamento de sua app, dentro de um dispositivo iOS. Sua app roda em uma estrutura de segurança conhecida como: “Sandbox”:



Sua app não pode acessar arquivos fora do “Sandbox”. Dentro dele, existe um “Data container” com 3 pastas superiores: “Documents”, “Library” e “Temp”. Estes diretórios são utilizados para finalidades diferentes:
  • Documents: Dados gerados pelo usuário. Os arquivos contidos nesta pasta são vistos pelo usuário e “backupeados” pelo iTunes. Preste muita atenção nisso!
  • Library: Dados que não devem ser vistos pelo usuário. Você não deve gravar arquivos do usuário nesta pasta ou abaixo dela;
  • Temp: Arquivos temporários. Você deve apagar os arquivos quando terminar de usá-los. Note que o iOS pode apagar essa pasta, se precisar de espaço.



Ok. E onde eu gravo meus arquivos?



Você deve prestar atenção ao fato que algumas pastas podem ser copiadas pelo iTunes e também compartilhadas no iCloud. Isso deve guiar sua decisão.



Você pode perfeitamente gravar dados em Documents, ou em qualquer pasta abaixo dela. Porém, note que essa pasta deve conter arquivos criados pelo usuário, e que podem ser compartilhados por ele.



Existem duas pastas abaixo de “Library” que podem ser utilizadas com menos restrições:
  • Caches”: Os arquivos dentro desta pasta não são salvos pelo iTunes. Você pode usá-la para criar arquivos da aplicação, porém, seu uso mais específico é para conteúdo baixado, que não deve ser visto pelo usuário;
  • Application support”: Serve para guardar quaisquer documentos que sua app necessite, mas que não sejam arquivos do usuário. Esta pasta é copiada pelo iTunes. Você deve colocar seus arquivos e pastas dentro de uma pasta, cujo nome é o identificador do seu Bundle.



Como você pode acessar essas pastas?



Documents:



Já vimos como acessar a pasta Documents, com a classe NSFileManager:



let diretorios : [String] = NSSearchPathForDirectoriesInDomains( .DocumentDirectory,.UserDomainMask, true) as [String]



Temp:



Use a função NSTemporaryDirectory():



let tempPath = NSTemporaryDirectory()






Caches:



let dircache = NSSearchPathForDirectoriesInDomains(.CachesDirectory, .UserDomainMask, true) as [String]

let cachesDir = dircache[0]



Application support:






let apscache = NSSearchPathForDirectoriesInDomains(.ApplicationSupportDirectory, .UserDomainMask, true) as [String]

let apsDir = apscache[0]



Usando URLs em vez de caminhos de arquivo



A recomendação da Apple é usar URLs ao invés de caminhos do file system. Existe o método “URLsForDirectory”, que retorna a URL, ao invés do pathname:



func URLsForDirectory(_ directory: NSSearchPathDirectory,
inDomains domainMask: NSSearchPathDomainMask) -> [AnyObject]






Salvando com Core Data




Se a estrutura de dados que você quer salvar for mais complexa, por exemplo, contendo relacionamentos entre objetos, é melhor usar o Core Data.



O que é o Core Data? É um framework para lidar com o ciclo de vida de objetos, incluindo grafos formados pelos seus relacionamentos. Entre outras coisas, o Core Data permite:
  • Persistir uma estrutura de objetos;
  • Recuperar, agrupar e organizar uma estrutura de objetos;
  • Controlar alterações e permitir desfaze-las.



É uma mistura de JDO – Java Data Objects, com Hibernate, que permite, no final das contas, ler e gravar dados no dispositivo (e no iCloud!)



Mas eu posso ler e gravar em um Banco de dados?



Pode sim. Na verdade, o Datastore (onde você vai gravar) é independente do modelo de objetos.









Arquitetura




Para entender Core Data, é preciso estudar um pouco da sua arquitetura. Como é muita coisa, eu preferi criar um resumo do que você precisa saber para ler e gravar um Banco de Dados SQLite, usando o Core Data.



Managed Object Context



O Managed Object Context (MOC) é como um quadro de sala de aula. Nele, você carrega instâncias de Objetos, cria novas instâncias, altera instâncias etc. Só que ele fica residente em memória. Quando você carrega um Managed Object, ele o recupera a partir do Managed Object Model, que acessa o Persistent Store Coordinator, para fazer a transferência física e a de-serialização.



Managed Object Model



Pense nele como o “esquema” do Banco de dados, ou seja, quais entidades existem e qual o seu relacionamento. O Xcode possui um editor visual para você criar o modelo de dados. Ele faz a interface entre o Managed Object Context e o Persistent Store Coordinator.



Persistent Store Coordinator



Esta é a conexão física com o banco de dados. O PSC é responsável por ler e gravar os arquivos físicos.



Nada como um “hello world”




Ok. Você deve ter entendido como funciona o Core Data, pelo menos o básico. Vamos criar uma app muito simples:
  1. Escolha o template “Single View”;
  2. Antes de salvar, marque a caixa “Use Core Data”, na tela de salvar o projeto. Isto fará com que o framework seja incluído em seu projeto.



Não se preocupe! Se você não conseguir criar o projeto, ou não quiser perder tempo, ele está em “capt5/CoreDataDemo.zip”.

Nós vamos criar uma app que grava dados e mostra na tela, usando uma TextView:


  Em seu evento “viewDidLoad()” ela verifica se os dados existem, criando-os. Depois, os lê e adiciona a uma Text View.



Resetando o simulador



Você vai começar a gravar dados no Simulador. Se quiser zerar tudo, pode simplesmente selecionar o IOS Simulator, ir ao menu do OSX: “IOS Simulator” e selecionar “Reset content and settings”. Isso apagará quaisquer arquivos ou bancos de dados que você tenha criado.



Modelando os dados



Bem, olhando o projeto, você notará que existe um arquivo novo, com extensão: “.xcdatamodeld”. Ele é a definição do seu Managed Object Model. Nele, você pode definir Entidades e Relacionamentos. Abra este arquivo e notará que não existe muita coisa nele.



Vamos criar uma entidade chamada “Usuario” (sem acento). Basta clicar no botão “Add Entity”, que fica na parte de baixo à esquerda, da janela central (o editor).



Ele vai criar uma entidade chamada “Entity”. Clique duas vezes, com um intervalo maior entreos cliques, sobre o nome “Entity” e você poderá alterá-lo para “Usuario”, conforme a figura:



Vamos criar dois atributos: “username” e “password”, ambos Strings. Basta clicar no sinal “+” na lista “Attributes”, digitar o nome e escolher o tipo. Veja a figura a seguir.





Crie uma nova entidade chamada “Mensagem”, com dois atributos: “texto” (String) e data (Date).



Agora, vamos criar um relacionamento entre eles. Selecione a entidade “Usuario” e adicione um relacionamento para a entidade “Mensagem”, conforme a figura:





O relacionamento só é navegável em uma direção, pois a propriedade “No inverse” está selecionada. Ok, salve o modelo.
Vamos editar as propriedades das entidades, para vermos como fazer. Selecione a entidade “Usuario” e, na área de Utitities, abra o “Data Model Inspector”, conforme a figura:





Aqui, podemos mudar várias propriedades da Entidade “Usuario”. Selecione a propriedade “username”, no editor do data model, e você verá aparecerem suas propriedades aqui:




A propriedade “username” é obrigatória, pois a caixa “Optional” está desmarcada. Nós queremos que a entidade Usuário seja indexada pela propriedade “username”, pois isso facilita a busca e ordena os resultados. Também especificamos um tamanho mínimo de 5 caracteres e máximo de 30.



Na propriedade “password”, faça a mesma coisa, exigindo tamanho mínimo de 8 caracteres.



Agora, vamos editar o relacionamento. Selecione-o e vamos ver suas propriedades no Data Model Inspector:




Demos o nome de “mensagens”, e indicamos que ele é opcional, ou seja, pode existir um Usuário sem Mensagem alguma. Note que “Destination” está apontando a entidade “Mensagem”. O tipo do relacionamento é “To Many” (para muitos), ou seja, um Usuário pode ter Zero ou Várias mensagens.



Criando classes para representar as entidades



Embora possamos trabalhar com instâncias de NSManagedObject (a classe que o Core Data usa para os objetos), é melhor criarmos classes derivadas para usarmos em nosso código. E o Xcode ajuda bastante! Com o “xcdatamodel” aberto, selecione o menu “Editor / Create NSManagedObject subclasses...”.



Na janela de seleção de Data Model, marque o Data Model atual (CoreDataDemo, no meu caso) e clique em “next”. Selecione as duas entidades (Usuário e Mensagem), clicando em “next”. Selecione o diretório raiz do projeto e pronto!



Você verá dois arquivos Swift novos: “Usuario.swift” e “Mensagem.swift”.



Antes de utilizarmos esses arquivos, precisamos dar uma preparada no nosso Data Model. O Swift identifica as classes por NameSpace. Todo projeto é um NameSpace, logo, as classes que vamos usar são “<projeto>.Usuario” e “<projeto>.Mensagem”. Temos que informar isso no Data Model.



Abra novamente o datamodel, selecione a entidade “Usuario” e abra o Data Model Inspector. Mude a propriedade “Class” para “<nome do projeto>.Usuario” (no meu caso é “CoreDataDemo.Usuario”). Faça o mesmo para a entidade “Mensagem”.



Como funciona o Core Data



Para começar, seus ManagedObjects possuem uma chave primária, gerada pelo Core Data, chamada “objectId”, do tipo NSManagedObjectID, que identifica cada instância persistida. Essa propriedade é inicializada quando o Objeto é persistido.



Em outras palavras, não você não pode indicar uma “Chave Primária”. Ok, mas você pode criar um índice, certo? O problema é que você não pode criar um índice único, como faria no SQLite nativo. A única alternativa para evitar duplicidade é você ler antes de gravar.



Isso posto, como será nosso algoritmo? Bem, vamos tentar ler o usuário, e, se não existir, nós o criaremos e leremos novamente. Eis o começo do código, no ViewController.swift:


import UIKit
import CoreData

class ViewController: UIViewController {

  @IBOutlet weak var textview: UITextView!
  
  override func viewDidLoad() {
    super.viewDidLoad()
    textview.text = ""
    if !lerDados() {
      gravaDados()
      lerDados()
    }
  }

Note que importamos o framework Core Data e criamos uma “outlet” para a nossa Text View. O método “viewDidLoad()” tenta ler os dados e, caso não existam, ele os grava e depois le novamente.



Desta forma, evitamos gravar duplicidade.



Gravando objetos



Vamos gravar uma instância de “Usuario” e algumas de “Mensagem”. Primeiramente, precisamos instanciar o Managed Object Context:


  func gravaDados() {
    
    var erro: NSError?
    
    // Instanciando o ManagedContext:
    let delegate = UIApplication.sharedApplication().delegate as AppDelegate
    let managedContext = delegate.managedObjectContext!
	...

O Xcode já gerou um monte de coisas em nosso arquivo “AppDelegate.swift”. Podemos pegar o objeto “managedObjectContext” dele.



Antes de criar um usuário, vamos ver como é a classe “Usuario”, que o Xcode gerou para nós. Abra o arquivo “Usuario.swift”:



import Foundation
import CoreData

class Usuario: NSManagedObject {

    @NSManaged var password: String
    @NSManaged var username: String
    @NSManaged var mensagens: NSSet

}

O atributo “@NSManaged” informa que a memória e a implementação da propriedade serão fornecidas em runtime.



Então vamos gravar um usuário. Na função “gravaDados()”, insira isso:


    // Criando uma instância de Usuário:
    let novoUsuario = NSEntityDescription.insertNewObjectForEntityForName("Usuario", inManagedObjectContext: managedContext) as Usuario

    novoUsuario.username = "usuario1"
    novoUsuario.password = "senha123"
    novoUsuario.mensagens = NSMutableSet()

O método “insertNewObjectForEntityForName”, da classe NSEntityDescription, cria uma nova instância de Entidade do Managed Object Model, retornando uma instância de NSManagedObject. Nós estamos fazendo um “Down cast” para “Usuario”, nossa classe (que é derivada de NSManagedObject”).



Temos que inicializar as propriedades do novo objeto: dois Strings e um “Set”. Um Set (NSSet) é uma coleção, que não admite duplicidade de objetos. Nós a inicamos com a subclasse NSMutableSet, que cria um Set mutável, ou seja, ao qual podemos adicionar elementos.



Agora, vamos criar duas mensagens:



    // Criando algumas mensagens:
    let msg1 = NSEntityDescription.insertNewObjectForEntityForName("Mensagem", inManagedObjectContext: managedContext) as Mensagem
    msg1.data = NSDate()
    msg1.texto = "Mensagem 1"
    
    let msg2 = NSEntityDescription.insertNewObjectForEntityForName("Mensagem", inManagedObjectContext: managedContext) as Mensagem
    msg2.data = NSDate()
    msg2.texto = "Mensagem 2"

Ok, temos duas mensagens e agora queremos adiconá-las ao relacionamento com o Usuário. Não podemos simplesmente adicionar à propriedade “mensagens”? Não! A maneira correta com Swift é invocar o método “mutableSetValueForKey” e depois adicionar a ele. Veja como faremos:



var mensagens = novoUsuario.mutableSetValueForKey("mensagens");
mensagens.addObject(msg1)
mensagens.addObject(msg2)

Pegamos uma referência para o relacionamento usando o método “mutableSetvalueForKey”, informando o nome do relacionamento. Depois, é só adicionar os dois objetos.



Finalmente, se tudo estiver ok, podemos salvar o contexto, o que fará com que seja gravado na Persistent Store:


// Salvando:
if !managedContext.save(&erro) {
println("Erro ao gravar: \(erro), \(erro?.userInfo)")
}

Se der algum problema, o método “save”, do Managed Object Context, retornará false, e nossa variável “erro” conterá o erro ocorrido.



Lendo os dados



Agora, vamos fazer a função que lê os dados. Para começar, vamos criar um “fetch request” para trazer o Usuário, e depois vamos executá-lo. Crie uma função “lerDados()”, que retorne um Bool, indicando se conseguiu ler ou não:



  func lerDados() -> Bool {
    var retorno = false
    let delegate = UIApplication.sharedApplication().delegate as AppDelegate
    let managedContext = delegate.managedObjectContext!
    
    // criando um FetchRequest para o Usuario:
    let fetchRequest = NSFetchRequest(entityName: "Usuario")
    
    // Variável de erro:
    var erro: NSError?



Vamos executar o “fetch request” e obter um vetor de usuários:


    // Vamos executar o request e obter uma coleção de Usuários:
    if let usuarios = managedContext.executeFetchRequest(fetchRequest, error: &erro) as? [Usuario] {
      if usuarios.count > 0 {
        retorno = true
        for usuario in usuarios {
				….
        }
      }
    }


O método “executeFetchRequest” vai executar o nosso “fetch request” para a entidade Usuário, retornando um vetor de instâncias da classe “Usuario” ([Usuario]). Precisamos saber se tem algum usuário nele, antes de navegarmos.



Ok. Se não existir um usuário, retornaremos false. Porém, se existir, teremos que pegar o seu “username” e os textos das mensagens que existirem para ele:



textview.text = textview.text + "Usuário: " + usuario.username + "\n"
var mensagens = usuario.valueForKeyPath("mensagens") as NSSet
for mensagem in mensagens.allObjects as [Mensagem] {
textview.text = textview.text + "Mensagem: " + mensagem.texto + "\n"
}

O método “valueForKeyPath” vai retornar o Set das mensagens. Para obter as mensagens, podemos usar o método “allObjects”, que retorna um vetor contendo as instâncias de “Mensagem” que existirem no relacionamento.



Pesquisando, atualizando e removendo




Ok. Já vimos como persistir e recuperar objetos usando o Core Data. Mas como podemos pesquisar? E como podemos atualizar? e Remover?



Para ser o mais simples possível, eu criei outra versão da app CoreDataDemo, que tem um botão “Modificar”. Ao tocar neste botão, eu faço uma pesquisa na coleção de Usuários, pego o usuário que criamos, modifico uma de suas mensagens e deleto a outra.



Vamos ao código (“capt5/CoreDataDemo2.zip”):


  @IBAction func modificou(sender: AnyObject) {
    let delegate = UIApplication.sharedApplication().delegate as AppDelegate
    let managedContext = delegate.managedObjectContext!
    let fetchRequest = NSFetchRequest(entityName: "Usuario")
    fetchRequest.predicate = NSPredicate(format: "username == %@", "usuario1")
    var erro: NSError?
    if let usuarios = managedContext.executeFetchRequest(fetchRequest, error: &erro) as? [Usuario] {
      if usuarios.count > 0 {
        for usuario in usuarios {
          var mensagens = usuario.mutableSetValueForKey("mensagens") as NSMutableSet
          for mensagem in mensagens.allObjects as [Mensagem] {
            if mensagem.texto == "Mensagem 1" {
              mensagem.texto = "Mensagem 1 Modificada"
            }
            else {
              mensagens.removeObject(mensagem)
              managedContext.deleteObject(mensagem)
            }
          }
        }
      }
    }
    if !managedContext.save(&erro) {
      println("Erro ao gravar: \(erro), \(erro?.userInfo)")
    }

    textview.text = ""
    lerDados()
    
  }

Eu criei um botão, acresentei uma “action” ao ViewController. Esta “action” busca o usuário através do “username”, usando uma classe NSPredicate, indicando a condição para seleção do usuário, que é “username == usuário1”. Se atribuirmos isso à propriedade “predicate”, do nosso “fetch request”, a seleção será feita por ela.



Com isto, fizemos uma pesquisa no nosso Modelo, recuperando uma determinada entidade.



Se eu tiver uma entidade no meu Managed Object Context, posso alterar qualquer propriedade. Ao salvar o contexto, salvarei a alteração.



E, se eu quiser deletar uma instância, uso o método “deleteObject”, do Managed Object Context e pronto.



Para ficar mais legal, eu mostro como se faz isso em uma entidade que está em um relacionamento, no caso, Mensagem. Note que eu recuperei o relacionamento como um NSMutableSet, já que vou retirar elementos:



var mensagens = usuario.mutableSetValueForKey("mensagens") as NSMutableSet



Se eu quiser remover um objeto de um relacionamento, basta removê-lo do Set:



mensagens.removeObject(mensagem)



Finalmente, eu deleto o objeto do Managed Object Context:



managedContext.deleteObject(mensagem)



Com isto, concluímos nosso estudo de Core Data, por enquanto.



Conclusões




Esse foi um capítulo trabalhoso, não? Porém, agora você tem uma ideia melhor de como sua app deve se comportar, não? Já vimos os estados da app, e também as localizações onde você pode gravar dados e suas consequências.



Se você vai armazenar dados, eu recomendaria fortemente que usasse Propriedades, através do NSUserDefaults, ou então guardasse em objetos gerenciados pelo Core Data.



Se for o caso de você querer pré instalar dados, ou seja, tem um Banco que precisa vir com conteúdo gravado, a melhor solução é recriá-lo, caso não exista.




Colocar Bancos de Dados e arquivos dentro do seu Bundle pode causar a rejeição de sua aplicação pela Apple.

Não se esqueça!

Acesse a página do curso para ver as outras lições, e sempre baixe novamente o zip do curso, pois, como é um trabalho em andamento, pode haver correções de erros e aprimoramentos.

Se tiver dúvidas, use o fórum!

Esse "curso" não dá diploma algum! E todo o material é liberado sob licença "Creative Commons" compartilha igual.

Você pode compartilhar esse material da forma que desejar, desde que mantenha o mesmo tipo de licença e as atribuições de autoria original.


O trabalho "Criando apps para iPhone com Swift " de Cleuton Sampaio de Melo Jr está licenciado com uma Licença Creative Commons - Atribuição-CompartilhaIgual 4.0 Internacional. Isso inclui: Textos, páginas, gráficos e código-fonte.