Во время разработки моего последнего приложения мне пришлось провести довольно много времени, экспериментируя с разными подходами к размещению 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?
По идее, так должно быть немного лучше, но я не пробовал.
Я осветил общую структуру решения, и, на мой взгляд, неочевидные моменты. Надеюсь, это кому-нибудь пригодится. Спасибо.