Подсветка кода на android. Мой опыт

  • Tutorial

Во время разработки моего последнего приложения мне пришлось провести довольно много времени, экспериментируя с разными подходами к размещению span'ов в EditText. В этом посте хотелось бы подвести некоторый итог этого времяпрепровождения, а также сэкономить время тем, кто в будущем будет решать подобные задачи.

Кода будет немного, только основные моменты.

Для начала хочу привести небольшой список фактов для того, чтобы ввести читателя в курс дела:

  • Несмотря на N ядер (каждое с огромной частотой), современный смартфоны все еще очень сильно уступают в производительности даже недорогим, но большим компьютерам.
  • Каждое приложении в андроиде имеет строго ограниченный размер выделяемой памяти. И он не велик.
  • Метод setSpan работает медленно.
  • Чем больше работы вы вынесете в Worker'ы, тем отзывчивее будет ваше приложение.
  • Держать подсвеченным весь текст не получится — только видимую его часть.
  • Довольно очевидно, но все же: поиск места размещения спана в UI потоке делать не получится.




Итак, сразу к моему решению, которое, возможно, далеко не самое оптимальное. В этом случае буду рад советам.

Общие описание структуры предлагаемого решения



Создаем расширение ScrollView и в него помещаем EditText. У ScrollView переопределяем onScrollChanged для того, чтобы отлавливать момент окончания скроллинга. В это время уведомляем наш постоянно висящий в фоне поток о том, что текст надо распарсить.
EditText'у вешаем слушателя изменения текста — TextWatcher'а . В его методе afterTextChanged информируем Worker'а о том, что надо распарсить текст. В классе (потомке EditText) заводим Handler, в который из Worker'а будем отсылать список спанов, которые необходимо навесить на текст.

Общая схема такова. Теперь к деталями, которые изложу в форме вопрос-ответ.

Как отловить момент окончания скроллинга?


Метод onScrollChanged вызывается после каждого «проскролленого» пикселя, и если заставлять поток-парсер работать после каждого вызова, то, понятное дело, ничего хорошего из этого не выйдет. Поэтому делаем следующим образом:
private Thread timerThread;
protected void onScrollChanged(int x, int y, int oldx, int oldy) {
    super.onScrollChanged(x, y, oldx, oldy);
    
    timer = 500;
    
    if (timerThread == null || !timerThread.isAlive()) {
        timerThread = new Thread(lastScrollTime);
        timerThread.start();
    }
}

Runnable lastScrollTime = new Runnable() {
    @Override
    public void run() {
        while (timer != 0) {
            timer -= 10;
            try {
                Thread.sleep(10);
                } catch (InterruptedException e) {
            }
        }
        
        CustomScrollView.this.post(new Runnable() {
            @Override
            public void run() {
                if (onScrollStoppedListener != null) {
                    onScrollStoppedListener.onScrollStopped(CustomScrollView.this.getScrollY());
                }
            }
        });
    }
};

public interface OnScrollStoppedListener {
    void onScrollStopped(int scrollY);
}


То есть каждый раз при вызове метода выставляем таймер в 500 мс и, если в течении этого времени метод не вызывается, то уведомляем OnScrollStoppedListener о том, что скроллинг остановился. В моем случае интерфейс OnScrollStoppedListener реализует мой EditText.

Как не стартовать поток-парсер после каждого введенного символа?


См. предыдущий пункт.
На самом деле этот способ в данном случае далеко не идеален потому, что пользователю всегда придется ждать N-ое количество миллисекунд до начала процесса парсинга. По-хорошему тут нужна какая-то интеллектуальная система, которая будет понимать, когда пользователь просто медленно печатает, а когда он уже завершил некоторую операцию ( к примеру написал оператор echo).

Как понять, какой текст попадает в видимую область?


К сожалению, точно этого сделать нельзя, поэтому приходится делать примерно. Для начала после каждого изменения текста я вызываю следующий метод:

List<Integer> charsCountPerLine = new ArrayList<>();
public void fillArrayWithCharsCountPerLine(String text) {
    charsCountPerLine.clear();
    charsCountPerLine.add(0);
    BufferedReader br = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(text.getBytes())));
    int currentLineLength = 0;
    char current;
    try {
        while (true) {
            int c = br.read();
            if (c == -1) {
                charsCountPerLine.add(currentLineLength);
                break;
            }
            current = (char) c;
            currentLineLength++;
            
            if (current == '\n') {
                charsCountPerLine.add(currentLineLength);
            }
        }
        } catch (IOException e) {
        Log.e(TAG, "", e);
    }
}

То есть я получаю номер символа начала каждой строки. Затем, зная высоту экрана в пикселях, легко вычисляем номер первой и последней видимой строки:
        int lineHeight = mEditText.getLineHeight();

        int startLine = scrollY / lineHeight; // scrollY - то что присылает нам ScrollView
        int endLine = mEditText.startLine + viewHeight / lineHeight + 1; // viewHeight  высота дисплея в пикселях


Имея эти данные, вы без труда найдете первый и последний видимый символ.

Зачем нужно заполнять список со спанами? Почему бы просто не посылать каждый спан в handler сразу после его создания?


Во-первых, тогда вы потеряете удобную возможность использовать несколько потоков для парсинга текста. В такой конфигурации вы, к примеру, можете на этапе вставки спана в список проверять его на наличие двойника в листе. Во-вторых, на мой взгляд, программист работает итеративно. То есть он сделал какое-то действие, а затем не секунду задумался. В этот-то момент и придет пачка спанов в наш ui поток и подсветит его на доли секунды. В обратном же случае спаны будут приходить постоянно, создавая микро тормоза UI.

Зачем нам постоянно спящий поток? Почему бы не использовать ThreadPool?


По идее, так должно быть немного лучше, но я не пробовал.

Я осветил общую структуру решения, и, на мой взгляд, неочевидные моменты. Надеюсь, это кому-нибудь пригодится. Спасибо.
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 14
    0
    Не понял пару вещей.
    У вас данные по спану хранятся отдельно(может для этого у вас и используется спан список) и если скролить то они берутся из этого хранилища или каждый раз вычисляются, если даже ничего не введено(тогда какие функции у спан списка)?
    Если проскролить на одну строчку, то спан будет вычисляться только для новой строки или для всего что на экране?
      0
      У меня каждый раз вычисляются, так как я опасаюсь, что в каком-нибудь 3000+ строк кода файле будет их так много, что выскочит OOM. Но честно говоря я не пробовал, и в принципе попробовать стоит.

      Но спаны, которые уже размещены и которые в области видимости я не перевставляю в EditText.
        0
        Мне кажется можно попробовать хранить посчитанные спаны в бд, например, или в LRU каком-нибудь.
          0
          Можно, но мне кажется, все же узким место будем в любом случае UI thread и метод setSpan, так что не уверен что пользователь вообще заметит прирост перформанса. Но я все равно попробую, спасибо за мысль.
      0
      вы бы не могли оформить свое решение в форме опен сорс библиотеки? мне было бы интересно попробовать
      сам использовал для посветки кода CodeMirror вместе с WebView
        0
        У этой бы опен сорс библиотеки нашлось бы 3 пользователя за 3 года )). Мало кто будет писать IDE для андроид.

        По поводу CodeMirror. И как? Должно же просто дико тормозить, в javascript же вообще нет потоков и выполняется ( на сколько я знаю) на 1ом ядре все.
          +1
          отличный вам пример использования GitHub Android клиент. Там CodeMirror как раз используется для подсветки кода. Правда немного устаревшей версии, попробую позже обновить.

          То есть, здесь не только об IDE на андроид речь идет, всякие GitHub, Gitlab клиенты и другие приложения, отображающие код — потенциальные пользователи :)

          Ничего вам не могу сказать о потоках в javascript, не осведомлен. простое отображение кода без редактирования работает довольно шустро.
          Плюс с приходом полноценного Chromium движка для Android WebView уверен подобные вещи значительно ускорятся.
            0
            Так это вообще совсем другое дело. Просто подкрасить текст это совсем несложно. Другое дело, когда одна измененная буква требует перестройки AST и соответственно раскраски.
              0
              согласен. сам вот все хочу как то попробовать это реализовать.
              В любом случае, думаю ваш опыт был бы полезен сообществу :)
                0
                Конкурента хотите мне сделать? ))
                  +1
                  нет ) просто планировал как нибудь реализовать редактирование кода, так что ваша статья мне в помощь )
                0
                Зачем для подсветки AST, если в большинстве случаев справится и конечный автомат с магазинной памятью?
                  0
                  AST нужно не только для подсветки
                    0
                    Я понимаю, что у AST есть и другие задачи, но для подсветки-то зачем его использовать?

                    Допустим, мы показываем исходный текст на Java. В тексте есть строки с escape-последовательностями, которые хорошо бы выделять цветом/стилем. Чем вам тут поможет AST? Будете хранить один строковый литерал в виде целого поддерева или всё же обработаете все escape-последовательности ещё в лексере?

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое