Quando tarefas assíncronas produzem um UX ruim

9

Estou escrevendo um suplemento de COM que estende um IDE que precisa desesperadamente dele. Existem muitos recursos envolvidos, mas vamos reduzi-lo para 2 por causa deste post:

  • Há uma janela de ferramenta do Code Explorer que exibe uma visualização em árvore que permite ao usuário navegar pelos módulos e seus membros.
  • Há uma janela da ferramenta Inspeções de código que exibe uma visualização de datagrid que permite ao usuário navegar pelos problemas de código e corrigi-los automaticamente.

Ambas as ferramentas têm um botão "Atualizar" que inicia uma tarefa assíncrona que analisa todo o código em todos os projetos abertos; o Code Explorer usa os resultados da análise para criar a visualização em árvore , e o Code Inspections usa os resultados da análise para encontrar problemas de código e exibir os resultados em sua visualização de datagrid .

O que estou tentando fazer aqui é compartilhar os resultados da análise entre os recursos, para que, quando o Code Explorer for atualizado, as Inspeções de Código o reconheçam e possam se atualizar sem precisar refazer o trabalho de análise que o Code Explorer acabou de realizar. .

Então, o que fiz foi transformar minha classe de analisador em um provedor de eventos no qual os recursos podem ser registrados:

    private void _parser_ParseCompleted(object sender, ParseCompletedEventArgs e)
    {
        Control.Invoke((MethodInvoker) delegate
        {
            Control.SolutionTree.Nodes.Clear();
            foreach (var result in e.ParseResults)
            {
                var node = new TreeNode(result.Project.Name);
                node.ImageKey = "Hourglass";
                node.SelectedImageKey = node.ImageKey;

                AddProjectNodes(result, node);
                Control.SolutionTree.Nodes.Add(node);
            }
            Control.EnableRefresh();
        });
    }

    private void _parser_ParseStarted(object sender, ParseStartedEventArgs e)
    {
        Control.Invoke((MethodInvoker) delegate
        {
            Control.EnableRefresh(false);
            Control.SolutionTree.Nodes.Clear();
            foreach (var name in e.ProjectNames)
            {
                var node = new TreeNode(name + " (parsing...)");
                node.ImageKey = "Hourglass";
                node.SelectedImageKey = node.ImageKey;

                Control.SolutionTree.Nodes.Add(node);
            }
        });
    }

E isso funciona. O problema que estou tendo é que ... funciona - quero dizer, quando as inspeções de código são atualizadas, o analisador diz ao explorador de código (e a todos os outros) "cara, alguém está analisando, alguma coisa que você queira fazer? " - e quando a análise é concluída, o analisador diz a seus ouvintes "pessoal, eu tenho novos resultados de análise para você, qualquer coisa que você queira fazer sobre isso?".

Deixe-me mostrar um exemplo para ilustrar o problema que isso cria:

  • O usuário abre o Code Explorer, que informa ao usuário "espere, estou trabalhando aqui"; O usuário continua trabalhando no IDE, o Code Explorer se redesenha, a vida é linda.
  • O usuário então exibe as Inspeções de código, que dizem ao usuário "espere, estou trabalhando aqui"; o analisador diz ao Code Explorer "cara, alguém está analisando, qualquer coisa que você queira fazer sobre isso?" - o Code Explorer diz ao usuário "espere, estou trabalhando aqui"; o usuário ainda pode trabalhar no IDE, mas não pode navegar no Code Explorer porque está atualizando. E ele também está aguardando a conclusão das inspeções de código.
  • O usuário vê um problema de código nos resultados da inspeção que deseja resolver; eles clicam duas vezes para navegar até ele, confirmam que há um problema com o código e clique no botão "Corrigir". O módulo foi modificado e precisa ser analisado novamente, para que as inspeções de código continuem com ele; o Code Explorer diz ao usuário "espere, estou trabalhando aqui", ...

Veja onde isso está indo? Eu não gosto, e aposto que os usuários também não vão gostar. o que estou perdendo? Como devo compartilhar os resultados da análise entre os recursos, mas ainda deixar o usuário no controle de quando o recurso deve fazer seu trabalho ?

A razão pela qual estou perguntando é porque achei que se eu adiasse o trabalho real até o usuário decidir ativamente atualizar e "armazenasse em cache" os resultados da análise à medida que eles chegassem ... bem, eu estaria atualizando uma exibição em árvore e localizando problemas de código em um resultado de análise possivelmente obsoleto ... o que literalmente me leva de volta à estaca zero, onde cada recurso trabalha com seus próprios resultados de análise: existe alguma maneira de compartilhar resultados de análise entre recursos e ter um UX adorável?

O código é , mas não estou procurando por código, estou procurando por conceitos .

Mathieu Guindon
fonte
2
Apenas um FYI, também temos um site UserExperience.SE . Acredito que isso esteja no tópico aqui porque está discutindo mais o design de código do que a interface do usuário, mas eu queria que você soubesse caso suas alterações se desviassem mais para o lado da interface do usuário e não para o lado do código / design do problema.
Quando você está analisando, essa é uma operação de tudo ou nada? Por exemplo: uma alteração em um arquivo aciona uma nova análise completa ou apenas para esse arquivo e para aqueles que dependem dele?
Morgen
@Orgen, há duas coisas: VBAParseré gerada pela ANTLR e me fornece uma árvore de análise, mas os recursos não consomem isso. Ele RubberduckParserpega a árvore de análise, a percorre e emite um VBProjectParseResultque contém Declarationobjetos que foram todos Referencesresolvidos - é isso que os recursos levam para entrada .. então sim, é praticamente uma situação de tudo ou nada. O RubberduckParseré inteligente o suficiente para não analisar novamente os módulos que não foram modificados. Mas se houver um gargalo, não é a análise, é a inspeção do código.
Mathieu Guindon
4
Penso que faria assim: quando o usuário aciona uma atualização, essa janela da ferramenta aciona a análise e mostra que está funcionando. As outras janelas da ferramenta ainda não foram notificadas, continuam exibindo as informações antigas. Até o analisador terminar. Nesse ponto, o analisador sinalizaria todas as janelas de ferramentas para atualizar sua visualização com as novas informações. Caso o usuário vá para outra janela de ferramenta enquanto o analisador estiver trabalhando, essa janela também entrará no estado "trabalhando ..." e sinalizará uma nova análise. O analisador seria iniciado novamente para fornecer informações atualizadas para todas as janelas ao mesmo tempo.
Cmaster - restabelecer monica
2
@master Eu gostaria de votar esse comentário como resposta também.
RubberDuck

Respostas:

7

A maneira como eu provavelmente abordaria isso seria focar menos em fornecer resultados perfeitos e, em vez disso, focar em uma abordagem de melhor esforço. Isso resultaria em pelo menos as seguintes alterações:

  • Converta a lógica que atualmente inicia uma nova análise para solicitar, em vez de iniciar.

    A lógica para solicitar uma nova análise pode acabar parecida com esta:

    IF parseIsRunning IS false
      startParsingThread()
    ELSE
      SET shouldParse TO true
    END
    

    Isso será emparelhado com a lógica que envolve o analisador, que pode ser algo como isto:

    SET parseIsRunning TO true
    DO 
      SET shouldParse TO false
      doParsing()
    WHILE shouldParse IS true
    SET parseIsRunning TO false
    

    O importante é que o analisador seja executado até que a solicitação de análise mais recente seja atendida, mas não mais do que um analisador esteja sendo executado a qualquer momento.

  • Remova o ParseStartedretorno de chamada. Solicitar uma nova análise agora é uma operação de ignorar e esquecer.

    Como alternativa, converta-o para não fazer nada além de mostrar um indicador refrescante em alguma parte da GUI que não bloqueia a interação do usuário.

  • Tente fornecer um tratamento mínimo para resultados obsoletos.

    No caso do Code Explorer, isso pode ser tão simples quanto procurar um número razoável de linhas para cima e para baixo para um método para o qual o usuário deseja navegar ou o método mais próximo se um nome exato não for encontrado.

    Não tenho certeza do que seria apropriado para o Code Inspector.

Não tenho certeza dos detalhes da implementação, mas, no geral, isso é muito parecido com o modo como o editor NetBeans lida com esse comportamento. É sempre muito rápido ressaltar que está atualizando, mas também não bloqueia o acesso à funcionalidade.

Resultados obsoletos geralmente são bons o suficiente - especialmente quando comparados a nenhum resultado.

Morgen
fonte
1
Excelentes pontos, mas tenho uma pergunta: estou usando ParseStartedpara desativar o botão [Atualizar] ( Control.EnableRefresh(false)). Se eu remover esse retorno de chamada e permitir que o usuário clique nele ... então me colocaria em uma situação em que tenho duas tarefas simultâneas fazendo a análise ... como evito isso sem desativar a atualização de todas as outras funcionalidades enquanto alguém está analisando?
Mathieu Guindon
@ Mat'sMug Atualizei minha resposta para incluir essa faceta do problema.
Morgen
Concordo com essa abordagem, exceto que eu ainda manteria um ParseStartedevento, caso você queira permitir que a interface do usuário (ou outro componente) avise algumas vezes ao usuário que uma nova análise está ocorrendo. Claro, você pode querer chamadores documento deve tentar não parar o usuário de usar o (prestes a ser) resultados de análise atuais obsoletos.
Mark Hurd