Criando Publishers para HealthKit

- 9 mins en | pt_BR |

Combine é um framework para escrita de código reativo, nos dando Publishers e operators para construção de pipelines complexas e poderosas, network requests, e mais. HealthKit é outro framework, mantido pela Apple, que gerencia acesso de leitura e compartilhamento de dados relacionados a saúde. Está disponível para iOS e watchOS.

Nesse post, nós vamos explorar como Combine pode ser usado para implementar funções essenciais de apps que usam HealthKit, como: solicitar permissão para ler dados de usuárias e criar queries para acessar esses dados.

Solicitando permissão

Solicitar permissão para ler dados é um passo importante e precisamos fazer isso antes de tentar acessar qualquer tipo de dados de saúde. Usuárias podem mudar as permissões para nossos apps a qualquer momento usando o Health app ou através do Settings. Nossos apps não serão notificados dessas mudanças.

Para realizar essa tarefa precisamos passar um set com o HKSampleType dos tipos de dados que desejamos acessar para o método requestAuthorization(toShare:read:completion:). É possível especificar tipos diferentes para leitura e compartilhamento.

Inspirada pela mais recente atualização do Apple’s Women Health Study, nós vamos solicitar acesso ao HealthKit para dados de sintomas. Especificamente, o sintoma de cólica (abdominal cramps) — esse foi o sintoma mais frequente entre as participantes do estudo. Você pode checar a lista completa de sintomas disponíveis aqui (tem vários). Aqui está uma implementação simples do requestAuthorization.

// the HKHealthStore handles requesting authorization, querying, etc.
let healthStore = HKHealthStore()
private let healthTypes = Set([HKSampleType.categoryType(forIdentifier: .abdominalCramps)!])

func requestHealthDataAccess(_ completion: @escaping (Bool) -> Void) {
    healthStore.requestAuthorization(toShare: healthTypes, read: healthTypes) { success, error in
        if let error = error {
            print("HealthKit authorization error:", error.localizedDescription)
        }
        completion(success)
    }
}

Essa é uma abordagem simples e com um formato familiar. Mas vamos ver como Combine pode nos ajudar a deixar essa implementação ainda melhor com um Future.

Solicitando permissão com um Future

De acordo com a documentação oficial, um Future é um publisher que eventualmente produz um único valor e então encerra ou falha. É perfeito para implementar uma solicitação de permissão para leitura de dados. E, por Future ser um Publisher, vamos poder usar todos os operadores de Combine para fazer as transformações que queremos.

Futures também são ótimos para converter código que foi escrito de forma imperativa para uma forma mais declarative. Para criar um, precisamos passar uma promise para ele. Em Combine, uma promise é só uma closure que recebe um Result. Então, o Future vai emitir seu valor. O exemplo a seguir re-implementa o método anterior, usando um Future.

import Combine 

func authorizationPublisher() -> Future<Bool, Error> {
    Future { [unowned self] promise in
        self.healthStore.requestAuthorization(toShare: healthTypes, read: healthTypes) { authSuccess, error in
            guard error == nil else {
                print("HealthKit authorization error:", error!.localizedDescription)
                promise(.failure(error!))
                return
            }
                
            promise(.success(authSuccess))
        }
    }
}

Vamos dar uma olhada na assinatura do método authorizationPublisher(). Ele retorna um Future<Bool, Error> , um Publisher que pode emitir uma bool ou um erro. Note que não precisamos chamar nenhum callback.

Dentro da função, nós inicializamos o Future com uma closure. É nessa closure que vamos realizar qualquer chamada assíncrona para solicitar permissão, da mesma forma que fizemos antes. A diferença é que agora ao invés de chamar um callback, vamos chamar uma promise. Se algum erro ocorreu, chamamos a promise com um result .failure(error!). Se não, chamamos a promise e passamos a booleana authSuccess.

Se você rodar esse código, vai notar que o Future executa imediatamente — assim que é criado. É assim que Futures funcionam, diferentemente de outros publishers de Combine. Em alguns cenários, a gente pode preferir criar o publisher e apenas dar trigger no fluxo de autorização quando dermos subscribe no Future. Para fazer isso, precisamos encapsular o Future em um outro publisher, o Deferred.

func authorizationPublisher() -> Deferred<Future<Bool, Error>> {
    Deferred {
        Future { [unowned self] promise in
            // .. 
    }
}

Agora, a execução da closure do Future será adiada (deferred) até que receba um subscriber, permitindo que chamemos o método authorizationPublisher() sem automaticamente der trigger no fluxo de autorização do HealthKit. O Deferred também vai garantir que cada novo subscriber em uma mesma instância do publisher vai fazer com que a closure execute novamente. É exatamente isso que queremos, afinal, tentar acessar dados sensíveis de saúde sem checar se temos acesso primeiro vai causar uma exceção caso não tenhamos acesso.

Checando se HealthKit está disponível no dispositivo

Até agora, nosso fluxo de solicitação de permissão não leva em consideração os dispositivos suportados pelo HealthKit. Como mencionei antes, HealthKit só está disponível em iOS e watchOS. Para checar se o dispositivo atual é suportado pelo HealthKit, basta chamar isHealthDataAvailable(). Se não estiver, iremos usar o caso unavailableOnDevice do nosso enum customizado HealthDataError.

func authorizationPublisher() -> Deferred<Future<Bool, Error>> {
    Deferred {
        Future { [unowned self] promise in
            // Check availability on the device. 
            guard HKHealthStore.isHealthDataAvailable() else {
                promise(.failure(HealthDataError.unavailableOnDevice))
                return
            }
                
            // Request authorization
            self.healthStore.requestAuthorization(toShare: healthTypes, read: healthTypes) { authSuccess, error in
                guard error == nil else {
                    print("HealthKit authorization error:", error!.localizedDescription)

                    // If there was an error, call the promise with a .failure
                    promise(.failure(HealthDataError.authorizationRequestError))
                    return
                }
                    
                // Only call the promise with a .success if `authSuccess` is true
                if authSuccess {
                    promise(.success(true))
                } else {
                    promise(.failure(HealthDataError.authorizationRequestError))
                }
            }
        }
    }
}

// Possible errors that can occur
enum HealthDataError: Error {
    case unavailableOnDevice
    case authorizationRequestError
}

Criando queries para ler dados sobre sintomas

A mesma abordagem declarativa pode ser usada para criar queries e ler dados do HealthKit. É agora que Combine realmente brilha, permitindo que transformemos as samples do HealthKit em nossos próprios modelos. Se você quiser ver em detalhes como implementar queries, existe um guia excelente da Apple. Mas resumindo, vamos utilizar a HKSampleQuery, que é o jeito mais simples de acessar dados de saúde.

Iremos utilizar um PassthroughSubject, um publisher já pronto que nos permite injetar valores na cadeia reativa chamando send().

extension HKHealthStore {
    func subject(for sampleType: HKSampleType) -> AnyPublisher<[HKCategorySample], Error> {
        
        // Create the subject.
        let subject = PassthroughSubject<[HKCategorySample], Error>()
        
        // Initialize the query and provide a completion block.
        let query = HKSampleQuery(
            sampleType: sampleType,
            predicate: nil,
            limit: HKObjectQueryNoLimit,
            sortDescriptors: nil
        ) { _, samplesOrNil, error in
            
            // If something went wrong, send a completion to the subject.
            if let error = error {
                subject.send(completion: .failure(error))
                return
            }
            
            // Unwrap the queried samples and send them to the subject.
            // If the samples are nil, send a completion instead.
            guard let samples = samplesOrNil as? [HKCategorySample] else {
                subject.send(completion: .finished)
                return
            }
            
            subject.send(samples)
            subject.send(completion: .finished)
        }
        
        // Execute the query and return the subject
        execute(query)
        return subject.eraseToAnyPublisher()
    }
}

Um PassthroughSubject não vai guardar nenhum dos valores que emite. Ele vai apenas mandá-los para os subscribers. Diferentemente do CurrentValueSubject que guarda o valor mais recente.

Subjects são outra ferramenta útil para criar APIs declarativas. Podíamos também definir nosso próprio Publisher customizado para queries, mas isso requer um monte de boilerplate e o resultado é quase o mesmo. Subjects e Futures vão resolver a maioria dos problemas, mas caso você precise de mais flexibilidade, então a abordagem do Publisher customizado pode valer a pena.

Agora podemos combinar nossos dois publishers e construir uma pipeline que assíncronamente solicita autorização, cria uma query e mapeia os resultados para o nosso próprio modelo.

func symptomsPublisher() -> AnyPublisher<[Symptom], Never> {
    // Start with the authorization publisher
    authorizationPublisher() 
        .flatMap { _ in 
            // combine it with the query publisher 
            self.healthStore.subject(for: self.healthTypes.first!) 
        } 
        .map { samples in samples.map { Symptom($0) } }
        .assertNoFailure()
        .eraseToAnyPublisher()
}

O operador .flatMap vai combinar os dois publishers e desaninhar eles.

Estou usando assertNoFailure() para indicar que esse publisher nunca falha, e por isso o tipo de erro dele muda para Never. Num cenário real devemos tratar esses erros antes de fazer esse assert.

Criando múltiplas queries de uma vez só

O atualização do Women’s Health study que mencionei antes reportou um monte de outros sintomas como bloating, cansaço, acne e dores de cabeça. Vamos adicioná-lo ao nosso exemplo!

private let symptomTypes = Set([
    HKSampleType.categoryType(forIdentifier: .abdominalCramps)!,
    HKSampleType.categoryType(forIdentifier: .bloating)!,
    HKSampleType.categoryType(forIdentifier: .fatigue)!,
    HKSampleType.categoryType(forIdentifier: .acne)!,
    HKSampleType.categoryType(forIdentifier: .headache)!
])

Não existe uma query para o HealthKit que acesse múltiplos tipos de dados ao mesmo tempo, mas podemos alcançar esse comportamento usando a extensão que criamos antes e alguns outros operadores de Combine.

symptomTypes.map { 
    self.healthStore.subject(for: $0) // 1
        .compactMap { $0.compactMap(Symptom.init) } // 2
        .eraseToAnyPublisher()
}

Tem alguns novos elementos aqui.

  1. Iteramos no array symptomTypes e criamos um publisher para cada um.
  2. Usamos compactMap para converter as samples do HealthKit em Symptom

O tipo que essa expressão nos retorna é um array de publishers [AnyPublisher<Array<Symptom>, Error>], não é exatamente o que queremos. Trabalhar com um array de publishers significa criar subscribers para cada um, lidar com múltiplas subscriptions, etc. Por sorte, Combine tem ferramentas excelentes para mergear publishers. Vamos dar uma olhada em duas delas, Publisher.Merge e reduce.

Merge é um tipo de publisher que pode receber um, dois, três ou muitos publishers e combinar eles em um só. A gente vai usar a variação que mergeia muitos, MergeMany, e extender o protocolo de Sequence com um método que junta seus elementos em um só publisher.

extension Sequence where Element: Publisher {
    /// Merges a sequence of Publishers into a single Publisher.
    func merge() -> Publishers.MergeMany<Element> {
        Publishers.MergeMany(self)
    }
}

Com isso, não iremos mais precisar lidar com um array de publishers. No entanto, a ilusão de uma query multipla é quebrada pois esse publisher vai emitir emitir valores a cada query que retornar um resultado. Para resolver isso, iremos usar o operador reduce e chegar na versão final.

func batchSymptomPublisher() -> AnyPublisher<[Symptom], Error> {
    symptomTypes.map { self.healthStore.subject(for: $0) } // 1 
        .merge() // 2
        .compactMap { $0.compactMap(Symptom.init) }
        .reduce([], +) // 3
        .eraseToAnyPublisher() // 4
}

Vamos olhar o que está acontecendo passo a passo.

  1. Novamente, iteramos no symptomTypes e criamos um subject para cada sintoma. O tipo do resultado é um array de publishers.
  2. Então, mergeamos o array de publishers em um único publisher.
  3. Coletamos os elementos da stream e emitimos um único array, com todas as samples.
  4. Apagamos o tipo para ter uma assinatura de método mais limpa.

É isso! Esse publisher vai emitir um array com todos os sintomas que foram retornados pelas queries. Para esse exemplo, eu simplifiquei a pipeline e converti todas as samples para um único tipo de modelo Symptom. No entanto, você pode usar a mesma abordagem, combinar múltiplas queries e mapear elas para diferentes tipos modelos.

Próximos passos

Recapitulando, utilizamos Futures para escrever um fluxo de solicitação de autorização. Então, usando subjects, criamos um publisher para samples de HealthKit. Também vimos como combinar diferentes publishers com MergeMany e como expandir o comportamento deles com operadores como compactMap e reduce.

Essas snippets são bem simples e expressivas, e realizam funcionalidades que são normais no dia a dia de vários apps. Solicitar permissões, ler dados assíncronamente e transformá-los, são coisas comuns para muitos aplicativos, independente de usar HealthKit ou não. Tente identificar APIs imperativas que poderiam ser beneficiadas por uma abordagem declarativa, experimente, e me conta como foi!

Obrigada por ler. 🙂

Referências

  1. Atualização do Women’s Health study
  2. Documentação oficial sobre solicitação de autorização no HealthKit
  3. Excelente artigo sobre Futures pelo Donny Walls
  4. Artigo da Apple sobre ler dados do HealthKit
  5. Lista de sintomas disponíveis no HealthKit
rss facebook twitter github gitlab youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora quora