esse foi o bug mais teimoso que ja enfrentei no Mayinab.
o sintoma: ao navegar rapido entre subjects e subtopics, o app congelava permanentemente. nao era lag. era freeze. tinha q matar o processo e reabrir. (ꐦ°᷄д°᷅)
e o bug era intermitente. funcionava de boa por um tempo, ai do nada travava. impossivel de reproduzir consistentemente, a nao ser com stress test de cliques rapidos.
As 6 Tentativas que Falharam
eu passei DIAS achando q era o GptMarkdown / flutter_math_fork (pq ja tinham me dado dor de cabeça antes com o bug do LaTeX). tentei de tudo:
| # | O que tentei | Funcionou? |
|---|---|---|
| 1 | Substituir GptMarkdown por AppMarkdown nos flashcards |
|
| 2 | Remover IntrinsicWidth dos flashcards e checklists |
|
| 3 | Remover IntrinsicWidth de TODOS os tipos de nota |
|
| 4 | Debounce no callback do ChatScrollObserver |
|
| 5 | RepaintBoundary em cada nota e cada markdown |
|
| 6 | Remover toRebuildScrollViewCallback completamente |
a tentativa 4 foi a mais frustrante. o debounce so atrasava o loop pra ~30Hz em vez de eliminar. cada 32ms um setState rodava, a lista reconstruia com tamanhos levemente diferentes, o observer flipava de novo... loop infinito mais lento, mas ainda infinito. ¯\_(ツ)_/¯
A Causa Raiz: ChatScrollObserver
depois de 6 fracassos eu finalmente parei de culpar o markdown e fui ler o codigo fonte do ChatScrollObserver do pacote scrollview_observer. e la tava o monstro.
tres mecanismos interligados causavam o freeze:
1. Cadeia perpetua de addPostFrameCallback
o construtor do ChatScrollObserver agenda observeSwitchShrinkWrap(). esse metodo, internamente, agenda outro addPostFrameCallback. o reattach() chama innerReattachCallBack que dispara _setupSliverController(), que agenda mais um callback. resultado: loop infinito de callbacks que nunca para. eh um trem desgovernado.
// o construtor ja comeca agendando o caos
ChatScrollObserver(this.observerController) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
observeSwitchShrinkWrap(); // ← agenda OUTRO callback interno
});
}
2. standby() força re-layout sem correcao de posicao
o standby() chama viewport.markNeedsLayout() pra forcar re-layout, mas sem ChatObserverClampingScrollPhysics (que eu nunca configurei no ListView), a correcao de posicao nunca eh executada. o markNeedsLayout() so causa trabalho desnecessario que alimenta o loop de observacao.
3. Mismatch de shrinkWrap
ChatScrollObserver comeca com innerIsShrinkWrap = true, mas meu ListView.builder tinha shrinkWrap: false hardcoded. o observeSwitchShrinkWrap detecta a diferenca, flipa pra false, chama reattach(), que agenda novos callbacks... e o ciclo se repete pra sempre. ヽ(°〇°)ノ
A Solucao (Tentativa 7)
removi o ChatScrollObserver inteiramente. sem meias medidas.
// ANTES (bugado):
_chatScrollObserver = ChatScrollObserver(_observerController)
..fixedPositionOffset = 5;
// DEPOIS (funcional):
// nada. removido. bye bye.
mantive o ListObserverController + ListViewObserver puros (pq esses sao usados pra jumpTo, animateTo e paginacao via onObserve). eles nao tem os side effects malucos do wrapper de chat.
Bonus: Bugs que achei no caminho
enquanto investigava o freeze, achei mais 3 bugs escondidos q contribuiam pro caos:
era criado inline dentro do build() a cada rebuild, nunca era dispose()d. cada rebuild = novo FocusNode orfao. movido pra campo da classe com dispose() correto.
o _watchChanges() no PaginatedNotesNotifier criava nova subscription sem cancelar a anterior quando os parametros mudavam. adicionei _changeSubscription?.cancel() antes de criar nova.
o _refreshLoaded() substituia toda a state com nova lista mesmo quando o conteudo era identico (mesmos IDs, mesmos updatedAt). adicionei deep equality check pra pular quando nada mudou de verdade.
Resultado
| Metrica | Antes | Depois |
|---|---|---|
| Freeze intermitente | sim | nao |
dart analyze lib/ |
0 erros | 0 erros |
flutter test |
— | 720 passed |
| Tentativas | 6 falhas | 1 sucesso |
Licao aprendida
leia o codigo fonte dos pacotes que vc usa. eu perdi dias achando q era o GptMarkdown, o IntrinsicWidth, o layout... quando o problema era um pacote de scroll que eu nunca tinha aberto o source. se eu tivesse lido o construtor do ChatScrollObserver no dia 1, teria economizado 6 tentativas.
e desconfie de qualquer coisa que agenda addPostFrameCallback dentro de outro addPostFrameCallback. isso eh receita pra loop infinito. (⌐■_■)