Arquitetura Outbox: Projetada para evitar que mensagens sejam derramadas

Esta página é uma tradução automática do original em japonês. Ler o original em japonês

Tarefa: Para evitar o derramamento de mensagens, mesmo que você as limpe imediatamente

No SimpleMemo, a interface do usuário é limpa no momento em que o usuário toca no botão enviar e pode começar imediatamente a escrever a próxima nota. Esta é a UX que é a raiz do estilo Captio.

No entanto, a rede real tem uma latência de 300ms~2 segundos, pode falhar e pode estar offline em primeiro lugar. Muitos aplicativos aguardam a resposta da rede antes de limpar a interface do usuário. É seguro, mas o UX é ruim. Pelo contrário, se você limpar imediatamente, há o risco de perder a mensagem.

O padrão Outbox resolve esse requisito conflitante ao mesmo tempo. A mensagem é armazenada permanentemente no armazenamento criptografado local (Outbox) no momento do toque de transmissão, a interface do usuário é imediatamente apagada e a transmissão de rede é realizada em segundo plano. Se a transmissão for bem-sucedida, exclua-a do Outbox e, se falhar, ela tentará novamente automaticamente. Como resultado, deixa a mensagem não transmitida no terminal e a reenvia, e a latência da experiência está próxima de zero.

Fluxo de design de padrão Outbox

O seguinte é todo o fluxo desde tocar no botão enviar até que a mensagem seja enviada.

  1. Toque em transmissão → Primeiro, criptografe AES-GCM para Outbox e salve-o permanentemente
    No momento em que o usuário toca no botão enviar, a mensagem é criptografada com AES-GCM 256bit e armazenada permanentemente no armazenamento local do dispositivo. A interface do usuário não será apagada até que esta etapa seja concluída. Em outras palavras, o armazenamento Outbox será o primeiro quebra-mar a evitar derramamentos.
  2. Limpe a interface do usuário imediatamente (o usuário pode começar a escrever a seguinte nota)
    Outbox Quando o salvamento for bem-sucedido, limpe a visualização de texto e reproduza a animação de transmissão. Do ponto de vista do usuário, é sinônimo de "envisado". Não estou esperando por nenhuma resposta da rede. O objetivo do Send-to-Reset é ser curto sem sentir espera (0.25 segundos, incluindo animação).
  3. Envie um e-mail via Relay API em segundo planoEm paralelo com a limpeza
    UI, o SendManager envia uma solicitação HTTP para o Relay API em Trabalhadores Cloudflare. Este processo é completamente assíncrono e não bloqueia o segmento principal.
  4. Se for bem-sucedido, exclua do Outbox
    Relay Se você receber uma resposta 200 do API, exclua com segurança a mensagem correspondente do Outbox. Isso completa o ciclo de vida da mensagem.
  5. Se falhar, será reenviado automaticamente
    No caso de um erro de rede ou erro de servidor, deixe-o aguardando transmissão, quando a linha for retornada, quando o aplicativo for aberto, processamento em segundo plano (BGTaskScheduler. O momento da execução é decidido por iOS) e reenviá-lo. Há um limite superior no número de vezes para reenviar automaticamente.
  6. Se estiver offline, o NWPathMonitor detecta o retorno da linha e a reenvia automaticamente Se você estiver offline no momento do envio do
    , o NWPathMonitor do Network.framework monitora o status da linha e, quando detecta o retorno do Wi-Fi ou celular, ele reenvia automaticamente a mensagem não transmitida do Outbox (como no 5 acima, há um limite superior no número de reenvios automáticos).

Código do Gerenciador de envio

A função send() do SendManager é o ponto de entrada do padrão Outbox. Primeiro, salve-o no Outbox, faça fila se não houver conexão de rede e execute a transmissão imediatamente se houver uma conexão.

// 保存完了と送信結果を分ける説明用の簡略API
@MainActor
func send(message: String, onSaved: () -> Void,
          completion: @escaping (SendResult) -> Void) {
    // まずOutboxに保存(クラッシュや圏外で失わないように)
    let outboxMessage: OutboxMessage
    do {
        outboxMessage = try OutboxManager.shared.add(body: message)
    } catch {
        DispatchQueue.main.async { completion(.failure(error)) }
        return
    }

    // 同期保存に成功した場合だけ、メインスレッドでUIへ通知する
    onSaved()

    // ネットワーク接続がない場合はキューに入れたことを通知
    guard NetworkMonitor.shared.isConnected else {
        BackgroundTaskManager.shared.scheduleRetryTask()
        DispatchQueue.main.async { completion(.queued) }
        return
    }

    // 送信処理を実行
    performSend(message: message, outboxId: outboxMessage.id, ...)
}

O ponto importante é que o OutboxManager.shared.add(body:) grava no armazenamento persistente de forma síncrona. Quando esta chamada é bem-sucedida, a mensagem é armazenada com segurança no terminal. Não importa qual seja o resultado da transmissão de rede, a mensagem permanecerá no Outbox.

Implementação da criptografia AES-GCM

Mensagens armazenadas no Outbox são notas privadas para os usuários. Embora seja um armazenamento local, não pode ser armazenado em texto simples. Usando o AES-GCM do Apple CryptoKit, é uma implementação que não usa nenhum SDK externo para criptografia.

  • Apple CryptoKit — Disponível para iOS 13 e posterior. Não use SDK externo para processamento de criptografia (somente Apple genuíno).
  • Chave simétrica 256-bit — Salvar nas Chaves.
  • Criptografia com autenticação — AES-GCM garante a confidencialidade e a integridade dos dados ao mesmo tempo. Dados adulterados são detectados no momento da descriptografia.
enum OutboxEncryption {
    private static var encryptionKey: SymmetricKey {
        if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
            return SymmetricKey(data: existingKey)
        }
        let newKey = SymmetricKey(size: .bits256)
        let keyData = newKey.withUnsafeBytes { Data($0) }
        KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
        return newKey
    }

    static func encrypt(_ plainText: String) throws -> Data {
        guard let data = plainText.data(using: .utf8) else {
            throw OutboxError.encryptionFailed
        }
        let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
        guard let combined = sealedBox.combined else {
            throw OutboxError.encryptionFailed
        }
        return combined
    }
}

A chave é criada automaticamente quando necessário pela primeira vez e armazenada no Keychain. A configuração de salvamento é "apenas este dispositivo, lido após o primeiro desbloqueio" (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly), que não pode ser lido de outros aplicativos e não se move do backup para outro terminal. Se você desbloqueá-lo uma vez após a reinicialização, também poderá lê-lo no processamento em segundo plano durante o bloqueio. Além disso, o exemplo de código acima é uma versão simplificada no momento da redação do artigo, e o código atual não cria uma nova chave, tratando-a como "sem chave" quando o chaveiro não lê.

O significado de configurar o caminho de dados apenas com Apple genuíno

Todas as estruturas usadas na arquitetura Outbox são nativas da Apple.

  • CryptoKit — Criptografia AES-GCM. Nenhuma biblioteca de criptografia de terceiros é necessária.
  • Network.framework — Monitoramento do status da linha com o NWPathMonitor. A biblioteca Reachability não é necessária.
  • BackgroundTasks — Retentação de fundo por BGTaskScheduler.

O fato de não haver biblioteca externa no caminho de espera tem os seguintes significados.

  • Não afetado por atualizações externas — O processamento de aguardar a transmissão não para de funcionar devido à atualização ou encerramento da biblioteca de terceiros.
  • Segurança - O código de terceiros não entra no processamento (criptografia, armazenamento, transmissão) que toca o corpo do memorando que aguarda para ser enviado.

Além disso, todo o aplicativo também usa um SDK externo (Google sign-in, Firebase, AppsFlyer) (consulte Privacy First Design). O "apenas genuíno Apple" mencionado aqui é sobre o caminho dos dados que aguardam para serem transmitidos.

Separação da limpeza da interface do usuário e transmissão de rede

O valor verdadeiro do padrão Outbox está na separação completa da camada de interface do usuário e da camada de rede. Vamos dar uma olhada no manipulador de toque do botão de envio.

@objc private func sendButtonTapped() {
    PerformanceLogger.shared.beginSendToReset()
    let message = textView.text ?? ""
    isSending = true
    sendBarButton.isEnabled = false

    SendManager.shared.send(message: message, onSaved: { [weak self] in
        // 端末への保存成功後にだけ消す。ネットワーク応答は待たない。
        self?.performSendAnimation { [weak self] in
            guard let self = self else { return }
            // アニメーション中に編集された入力は残す。
            if self.textView.text == message {
                self.clearTextView()
            }
            PerformanceLogger.shared.endSendToReset()
        }
    }) { [weak self] result in
        self?.isSending = false
        self?.updateSendButtonState()
        switch result {
        case .success:
            PerformanceLogger.shared.logEvent(name: "SendSuccess")
        case .failure(let error):
            self?.handleSendError(error)
        case .queued:
            self?.showQueuedFeedback()
        }
    }
}

Neste exemplo simplificado, onSaved é chamado somente após salvar com sucesso no dispositivo; a partir daí, a animação é executada e o texto digitado é apagado. Se o salvamento falhar, o texto é mantido. completion é uma notificação separada sobre o resultado do envio ou a inclusão na fila; a limpeza da interface não espera uma resposta da rede.

O objetivo do Send-to-Reset é shortness que não parece esperar. 0.25 segundos incluindo animação. Imediatamente após tocar no botão enviar, o usuário pode começar a escrever a próxima nota. Este é o estilo Captio UX.

Perguntas frequentes

P. O que acontece com as notas enviadas offline?

É criptografado e salvo pelo Outbox e é reenviado automaticamente quando a linha é restaurada (detectada pelo NWPathMonitor), quando o aplicativo é aberto e o processamento em segundo plano é acionado. Há um limite superior no número de vezes para reenviar automaticamente e, para notas que atingiram o limite superior, pressione e segure em "Histórico" e selecione "Reenviar".

Q. As notas não serão perdidas mesmo se eu fechar o aplicativo?

Outbox é um armazenamento permanente. Mesmo que você feche o aplicativo ou reinicie o iPhone, ele permanecerá. Na próxima vez que o aplicativo for iniciado, ou a tarefa em segundo plano será retentada automaticamente.

Q. Por que você escolheu o AES-GCM?

Apple CryptoKit é suportado nativamente, e a detecção de adulteração também é possível com Criptografia Autenticada. O fato de não haver necessidade de uma biblioteca externa para processar criptografia também é uma vantagem.

P. Qual é o número de novas tentativas em caso de falha de transmissão?

Será enviado novamente automaticamente. O gatilho é o processamento em segundo plano quando a linha é retornada, quando o aplicativo é aberto (BGTaskScheduler. O tempo de execução é determinado por iOS). Os memorandos que não puderam ser enviados serão enviados novamente sem excluí-los da espera de transmissão, mas há um limite para o número de vezes que o reenvio automático.

Artigos relacionados

Referências