[Dia do Diário de Desenvolvimento1] Eu gosto tanto do Captio que queria fazer "essa experiência" novamente

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

Você conhece o aplicativo chamado Captio?

Escreva uma nota e pressione o botão enviar. Isso é tudo o que você precisa para receber um memorando no seu endereço de e-mail. No momento em que você o iniciar, o teclado será exibido e, se você escrever, enviá-lo e, se enviá-lo, ele desaparecerá. Não há nenhuma função desnecessária. Era apenas um aplicativo.

, "isso é tudo" foi incrivelmente confortável.

A ideia que tive no trem suburbano, a tarefa que de repente veio à mente durante a reunião e uma palavra que passou pela minha cabeça antes de ir para a cama - Captio imediatamente a entregou na minha caixa de correio. Não há problema em abrir o aplicativo de memorandos, selecionar uma pasta e adicionar um título. A experiência de "fricção zero" era algo que você não conseguia deixar de lado uma vez que você sabia.

O Captio encerrou o serviço.

Eu tentei vários aplicativos alternativos, mas nenhum deles estava longe dessa experiência. Inicialização lenta, há elementos desnecessários na interface do usuário e as notas permanecem após a transmissão - pode parecer uma diferença trivial, mas a essência da experiência criada pelo Captio foi o acúmulo de tais "coisas triviais".

Então eu decidi fazer isso sozinho. Experimente o Captio novamente.

Este é o registro de desenvolvimento de "SimpleMemo" Day1. Neste artigo, vamos desmontar a experiência Captio e registrar todos os julgamentos técnicos sobre como reproduzi-la em uma pilha de tecnologia moderna.

1. "O que" de Captio foi bom - eu tentei desmontar a experiência

Para verbalizar a experiência de Captio, tentei dividi-la em dois elementos de 3.

Elemento 1: Lançamento Instantâneo para Entrada

Captio, o teclado é exibido imediatamente e a entrada de texto é possível. A tela inicial é exibida após a tela inicial, e não há procedimento para pressionar o botão de criação de memorando a partir daí. Início = Entrada. Nós chamamos isso de "tempo desde a inicialização até a entrada de texto" Time-to-Text. O Captio teve menos de 0.5 segundos em experiência.

Elemento 2: Desaparece imediatamente após o envio (Limpar Instantânea Após o Envio)

Quando você pressionar o botão enviar, a tela será apagada imediatamente. " Enviando..." Não tenho tempo para esperar enquanto olho para a barra de progresso. No momento em que a nota que você escreveu desaparece, você pode passar para a próxima ação com uma sensação de segurança de "Oh, eu consegui enviá-la". Nós chamamos isso de "o tempo de pressionar o botão de envio para a interface do usuário é limpo" Send-to-Reset. O objetivo é esvaziar a tela antes que você sinta que estava esperando.

Elemento 3: Nada Desnecessário (Nada Desnecessário)

Função de pasta, função de tag, rich text, suporte para markdown - nenhum deles estava disponível no Captio. Há apenas um campo de entrada de texto e um botão de envio. Essa limpeza aproximou a carga cognitiva de zero. Um aplicativo que não precisa pensar no que fazer. Era Captio.

Este elemento 3 é reproduzido no desenvolvimento moderno iOS. Essa é a missão de "SimpleMemo".

2. Arquitetura: Por que o SwiftUI "não" é o UIKit

Escolher UIKit em vez de SwiftUI no desenvolvimento de iOS em 2026 pode parecer herético. No entanto, nosso objetivo principal prioritário era claro - Time-to-Text 500ms ou menos.

Reavaliação `body` do SwiftUI, inicialização `StateObject`, resolução `@Environment` - essas despesas gerais podem ser ignoradas no desenvolvimento normal de aplicativos, mas não podem ser ignoradas para obter "entrada inicial". Quero me concentrar no campo de texto rapidamente, mesmo com 1ms.

O que foi adotado lá é construção direta UIKit sem usar Storyboard.

Crie uma janela com SceneDelegate e defina o ComposeViewController diretamente para o rootViewController. Pule até mesmo o processo de análise do Storyboard.

// SceneDelegate.swift
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = ComposeViewController()
    window.makeKeyAndVisible()
    self.window = window
}

e concentre-se imediatamente na visualização de texto com `viewDidAppear` no ComposeViewController.

// ComposeViewController.swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    textView.becomeFirstResponder()
}

Com apenas este código 2, o caminho da inicialização para a exibição do teclado é o mais curto.

A meta era ficar em no máximo 500 ms. Em uma medição posterior por gravação de tela, feita no dia 11 do mês 8 de 2026 no iPhone 16e, com inicialização a quente, o intervalo entre tocar no ícone e o teclado estar totalmente visível foi de 0.4 segundos. Avaliei que otimizar esse caminho em SwiftUI seria difícil, por ser mais complicado controlar precisamente os momentos de atualização de `@FocusState` e de reavaliação de body. Não medi as duas implementações nas mesmas condições para compará-las.

O UIKit pode ser "antigo". No entanto, "rápido". Neste ponto 1, o UIKit ainda é a melhor escolha.

3. Projete para perceber "desaparece quando enviado" com um toque

Há a decisão de design mais importante para reproduzir a experiência de "desaparece quando enviado" do Captio. É para separar completamente a animação de transmissão e a limpeza da interface do usuário da resposta da rede.

O fluxo de transmissão de aplicativos comuns é o seguinte:

  1. Pressione o botão enviar
  2. Mostrar indicador de carregamento
  3. Aguardando uma resposta do servidor
  4. Limpe a interface do usuário se for bem-sucedida, exiba o erro se falhar

Neste fluxo, o atraso da rede será o atraso da interface do usuário como está. Nosso fluxo é assim:

  1. Pressione o botão enviar
  2. InstantaneamenteLimpe a interface do usuário (Animação: 0.25 segundos)
  3. Transmissão de rede em segundo plano
  4. Sucesso → Excluir silenciosamente do Outbox / Falha → Tente novamente em segundo plano
// ComposeViewController.swift
private func performSendAnimation() {
    UIView.animate(withDuration: 0.25,
                   delay: 0,
                   options: .curveEaseOut) {
        self.textView.alpha = 0
        self.subjectField.alpha = 0
    } completion: { _ in
        self.textView.text = ""
        self.subjectField.text = ""
        self.textView.alpha = 1
        self.subjectField.alpha = 1
        self.textView.becomeFirstResponder()
    }
}

Para usuários, no momento em que você pressiona o botão enviar, a nota "desaparece" e você pode escrever a próxima nota. O sucesso ou fracasso da rede é tratado em um local invisível para o usuário.

"Mas e se a transmissão falhar?" - A resposta a essa pergunta é a arquitetura Outbox na próxima seção.

4. Arquitetura Outbox para evitar derramamentos

Separar a interface do usuário de transmissão da resposta da rede significa que uma situação em que "a interface do usuário foi apagada, mas a transmissão de e-mail realmente falhou" pode ocorrer. Isso é absolutamente inaceitável. A nota que o usuário pensou ter sido "enviada" não foi realmente enviada - esta é uma perda fatal de confiança para o aplicativo de memorando.

O padrão Outbox foi adotado lá.

O fluxo de envio de mensagens é o seguinte:

  1. Toque no botão enviar: Criptografe a mensagem com AES-GCM e salve-a no Outbox (armazenamento local)
  2. UI clear: Limpe a tela instantaneamente com performSendAnimation
  3. Transmissão em segundo plano: Extraia a mensagem do Outbox e envie-a para o Relay API
  4. Sucesso: Excluir mensagem de Outbox
  5. Falha: Deixe-o aguardando a transmissão e reenvie-o mais tarde
  6. Offline: Monitore a conexão com o NWPathMonitor e reenvie automaticamente ao retornar

O importante é salvá-lo no Outbox antes de enviar para a rede. Como resultado, mesmo que o aplicativo trave repentinamente ou a energia do terminal caia, a mensagem escrita para Outbox permanecerá.

As mensagens armazenadas no Outbox são protegidas pela criptografia AES-GCM usando CryptoKit. A chave de criptografia é armazenada nas Chaves e não pode ser acessada de fora da sandbox do aplicativo.

// OutboxManager.swift
func enqueue(message: OutboxMessage) throws {
    let key = try KeychainHelper.getOrCreateSymmetricKey()
    let sealedBox = try AES.GCM.seal(
        message.plainData,
        using: key
    )
    let encrypted = EncryptedOutboxEntry(
        id: message.id,
        sealedData: sealedBox.combined!,
        createdAt: Date(),
        retryCount: 0
    )
    try persistenceStore.save(encrypted)
}

Além disso, como tudo é implementado por você mesmo, A dependência da biblioteca externa é zero. CryptoKit, NWPathMonitor, URLSession - todos concluídos com a estrutura original Apple. Estar livre de controle de versão de biblioteca de terceiros e auditoria de segurança é uma grande vantagem para equipes pequenas.

5. Relé API: Envio sem depender da ligação OAuth da caixa de correio

O mecanismo de transmissão do Captio não foi oficialmente divulgado. A razão para o anúncio de encerramento do desenvolvedor é que ele não é mais capaz de continuar gastando tempo para manter a estabilidade e a segurança do aplicativo, e o método de transmissão não é citado como motivo. No entanto, em geral, o envio contando com e-mail externo API é facilmente afetado por mudanças nos requisitos do provedor e restrições de acesso.

Escolhemos a configuração de Cloudflare Workers + Resend API.

Cloudflare Workers é uma plataforma de computação de ponta que executa código no data center mais próximo de usuários em todo o mundo. Solicitações do Japão são processadas na borda do Japão, e solicitações dos Estados Unidos são processadas na borda dos Estados Unidos. Isso quase elimina o problema de partida a frio em comparação com o sem servidor convencional (AWS Lambda, etc.).

Use Resend API para enviar e-mails. Resend é um e-mail API para desenvolvedores.

Fluxo de autenticação de e-mail

Quando o usuário define o endereço de e-mail de destino, a autenticação de e-mail é realizada com o código de autenticação 6 dígitos. Isso impede o envio não autorizado para endereços de e-mail de terceiros.

Limite de taxa de várias camadas

Limites de taxa multicamadas são implementados para evitar o uso não autorizado.

// Cloudflare Worker - Rate Limit Configuration
const RATE_LIMITS = {
  devicePerMinute: 30,    // 1デバイスあたり1分30回
  devicePerDay: 200,      // 1デバイスあたり1日200回
  ipPerHour: 120,         // 1IPあたり1時間120回
  globalPerDay: 300       // サービス全体で1日300回(調整可能)
};

Ao aplicar limites de taxa à camada 3 em uma unidade de dispositivo, unidade de IP e unidades globais, você pode bloquear fugas e ataques de botnet em um único dispositivo.

2026-09 Postscript: O limite superior para cada terminal e o limite superior do dia total 1 foram removidos em 2026 6. O limite máximo de transmissão atual é o limite superior por 1 horas por endereço IP.

Garantia de igualdade de poder

Para evitar a transmissão duplicada devido à instabilidade da rede, atribua um UUID a cada mensagem e execute uma verificação duplicada no lado do Relay API. Se a mesma mensagem UUID for reenviada, após o tempo 2, ela retornará uma resposta bem-sucedida sem enviar um e-mail. Isso impede que o usuário receba o mesmo memorando duas vezes quando combinado com o mecanismo de nova tentativa do padrão Outbox.

2026-10 Postscript: O servidor de transmissão atual não verifica a duplicação em si. Passe o ID da mensagem como a Chave de Idempotência da solicitação para Resend, e Resend lembra a mesma chave para 24 horas e retorna a mesma resposta de antes para a mesma solicitação de conteúdo. Portanto, o mesmo memorando não será enviado repetidamente durante as horas 24 (para mais detalhes, design do Relay API).

6. O que eu fiz hoje: Apagar o pequeno desconforto um por um

Grande parte do trabalho de implementação do Day1 foi gasto revisando a caixa de diálogo de classificação por estrelas. A avaliação do App Store é a tábua de salvação do aplicativo, mas não busque a avaliação de uma maneira que prejudique a experiência do usuário.

Redesenho das condições de exibição

As condições de exibição da caixa de diálogo de classificação por estrelas são definidas da seguinte forma:

  • Exibido para cada 100: Exibido apenas para usuários que usaram totalmente o aplicativo
  • Intervalo mínimo de 14 dias: Mais de 14 passou desde a última exibição
  • 30 não será exibido por 0000 dias após o fechamento: Se o usuário o fechar, o 30 não será exibido novamente por um dia

Modifique a área de toque do ícone de estrela

Apple As Diretrizes de Interface Humana recomendam que o tamanho mínimo do alvo de toque seja 44x44pt. A área de toque do ícone da estrela não atendia a esse padrão, então foi corrigido.

// StarRatingView.swift
private func createStarButton() -> UIButton {
    let button = UIButton(type: .custom)
    button.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
        button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
    ])
    return button
}

Corrigir distorção do ícone da estrela

Corrigido um problema em que o ícone de estrela estava distorcido devido à distribuição `.fillEqually` do UIStackView. `contentMode` foi definido como `.scaleAspectFit`, e a distribuição do StackView foi alterada para `.equalSpacing`.

Fluxo após avaliação

  • 5 estrelas: Transição para a tela de revisão de App Store (`SKStoreReviewController`)
  • 4 estrelas ou menos: Exiba silenciosamente o brinde de "Obrigado" e termine. Não leve avaliações negativas para App Store.

7. A inconsistência do status de exibição do histórico foi resolvida

Notei um bug na tela do histórico de transmissão (Histórico). Havia uma mensagem de que o status permanecia "Enviando", mesmo que a transmissão estivesse completa.

Depois de investigar a causa, descobriu-se que havia um problema com a lógica de atualização de status. Em implementações anteriores, o conteúdo do texto da mensagem foi usado como uma chave para atualizar o status. No entanto, se você enviar uma nota com o mesmo conteúdo mais de uma vez, uma pesquisa baseada em texto não poderá identificar a mensagem correta.

A correção foi simples e clara. Alteração da pesquisa baseada em texto para atualização de status baseada em ID. Cada mensagem recebe um UUID ao salvar Outbox, portanto, use este ID para atualizar o status do Histórico.

// HistoryManager.swift
// Before: テキストベースの検索(バグの原因)
// func updateStatus(forText text: String, status: SendStatus)

// After: IDベースのステータス更新
func updateStatus(forMessageId id: UUID, status: SendStatus) {
    guard let index = entries.firstIndex(where: { $0.messageId == id }) else {
        return
    }
    entries[index].status = status
    persistEntries()
}

Com esta modificação, não importa quantas vezes você envie uma nota com o mesmo conteúdo, o status de cada uma agora é exibido corretamente.

8 Obsessão Panóide com privacidade

Informações privadas são gravadas no aplicativo de memorando. Senhas, preocupações pessoais, ideias de negócios - é impossível prever o que os usuários escreverão. É por isso que a proteção de privacidade não é "suficiente", mas "excessiva" é a certa.

Sobreposição de privacidade no switcher de aplicativos

No comutador de aplicativos iOS, a tela do aplicativo é exibida como uma miniatura. Exibe uma sobreposição de privacidade quando o aplicativo migra para o plano de fundo para que o conteúdo da nota não possa ser visto na tela do comutador de aplicativos.

2026-09 Postscript: Esta proteção não está habilitada no aplicativo atual (o processamento de exibição permanece na fonte, mas não é chamado). O memorando exibido pode permanecer na imagem do comutador de aplicativos. Para mais informações, consulte o artigo sobre "Design de Privacidade".

URLSession Efêmera

A sessão de URL normal armazena cache, cookies e informações de autenticação no disco. Ao usar `URLSessionConfiguration.ephemeral`, nos certificamos de que essas informações não permaneçam no disco. Isso é para evitar que o conteúdo do memorando permaneça no armazenamento como um cache de comunicação.

Logs inofensivos

Tenha cuidado com o registro de depuração em desenvolvimento. " O processo aparentemente inofensivo de "output do conteúdo do memorando enviado para o registro" pode ser uma falha de segurança. Nossa política é clara:

  • O conteúdo do memorando é não registrado, mesmo que seja uma compilação DEBUG
  • Não use `localizedDescription` no log de erros, e apenas `type(of: error)` é registrado
  • Razão: Porque `localizedDescription` pode incluir a entrada do usuário
// NetworkManager.swift
// BAD: エラーの詳細をログに出力
// Logger.error("Send failed: \(error.localizedDescription)")

// GOOD: エラーの型のみをログに出力
Logger.error("Send failed: \(type(of: error))")

Pode parecer paranóico. No entanto, acho que esse grau de paranóia é necessário para confiar e usar o aplicativo de memorando.

9. Suporte de idioma 10: Quero entregá-lo aos usuários do Captio em todo o mundo

Captio tinha usuários em todo o mundo, principalmente em países de língua inglesa. Para entregar "SimpleMemo" aos antigos usuários, a linguagem 10 foi suportada desde a primeira versão.

Idiomas suportados: japonês, inglês, espanhol, francês, alemão, italiano, português, coreano, chinês (simplificado), árabe.

2026-09 Postscript: Na notação atual App Store, os idiomas suportados são japonês, inglês, espanhol, francês, alemão, português, russo, chinês (simplificado), chinês (tradicional) e árabe 10. O italiano e o coreano na lista acima não estão incluídos na notação atual.

Especialmente O suporte ao RTL árabe (da direita para a esquerda) foi um desafio técnico. Não apenas a direção do texto, mas também todo o layout da interface do usuário precisa ser espelhado. Ao usar consistentemente a restrição `leading`/`trailing` do Auto Layout, ele percebe um layout natural mesmo em um ambiente RTL.

Alterar idioma no aplicativo

Além da troca de idioma no aplicativo após iOS 16, um mecanismo de comutação de idioma exclusivo também é implementado. Quando o idioma é trocado, o ViewController é substituído pela transição `cross-dissolver` para fornecer uma experiência de comutação suave.

// LanguageManager.swift
func applyLanguageChange() {
    guard let window = UIApplication.shared.connectedScenes
        .compactMap({ $0 as? UIWindowScene })
        .first?.windows.first else { return }

    let newVC = ComposeViewController()
    newVC.view.frame = window.bounds
    UIView.transition(with: window,
                      duration: 0.3,
                      options: .transitionCrossDissolve,
                      animations: {
        window.rootViewController = newVC
    })
}

10. O aprendizado de hoje: O produto é um design de "sensação de segurança"

Depois de terminar o Day1, o que eu acho é que a essência deste produto é um design de "sensação de segurança".

Resuma os números técnicos:

  • Tempo para Texto: 500ms ou menos (alvo)
  • Enviar para redefinir: Falta que não parece esperar (meta)
  • Mensagem derramada: Prevenir com o padrão Outbox
  • Criptografia: AES-GCM (CryptoKit)
  • Dependência da biblioteca externa: Zero

No entanto, todos esses números estão resumidos em um objetivo chamado "senso de segurança".

Inicialização rápida → "Você pode escrever imediatamente a qualquer momento" tranquilidade.
Desaparece quando você o envia → Senso de segurança "Processado".
Contramedidas de derramamento → "Não desaparecerá sem transmissão" paz de espírito.
Criptografia → "Ninguém pode ver" paz de espírito.

Captio foi amado não por sua excelente função. " Porque a experiência de "usar com paz de espírito" foi completamente polida. Eu quero assumir isso.

11. Da próxima vez (Day2) Aviso

Day2 trabalhará nos seguintes temas:

  • Sofisticação da animação de transmissão: Do atual segundo 0.25 desaparece para uma animação mais agradável
  • Otimização do tempo das atualizações de status do histórico: Comparação de atualizações em tempo real com atualizações em lote
  • Você realmente precisa de um brinde de sucesso? : Consideração da opção de "não exibir" para feedback sobre a transmissão bem-sucedida

Visando a limpeza do "nada" do Captio, continue para o Day2.

Referências