Недостаточные пролеты

Интервалы - это мощные концепции, которые позволяют стилизовать текст на уровне символа или абзаца, предоставляя доступ к таким компонентам, как TextPaint и Canvas. В предыдущей статье мы говорили о том, как использовать Spans, какие Spans предоставляются «из коробки», как легко создавать свои собственные и как их тестировать.



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

Под капотом: как работают пролеты

Платформа Android обрабатывает стили текста и работает с промежутками в нескольких классах: TextView, EditText, классы макета (Layout, StaticLayout, DynamicLayout) и TextLine (частный класс пакета, используемый в Layout), и это зависит от нескольких параметров:

  • Тип текста: выбираемый, редактируемый или невыбираемый
  • BufferType
  • LayoutParams типа TextView
  • так далее

Платформа проверяет, содержат ли Spanned объекты экземпляры разных диапазонов платформы, и запускает различные действия.

Логика разметки и рисования текста сложна и распространяется на разные классы; В этом разделе мы можем представить только упрощенное представление о том, как обрабатывается текст, и только для некоторых случаев.

Каждый раз, когда диапазон изменяется, TextView.spanChange проверяет, является ли диапазон экземпляром UpdateAppearance, ParagraphStyle или CharacterStyle, и, если да, аннулирует себя, инициируя новое рисование вида.

Класс TextLine представляет строку стилизованного текста и работает специально с интервалами, которые расширяют CharacterStyle, MetricAffectingSpan и ReplacementSpan. Это класс, запускающий MetricAffectingSpan.updateMeasureState и CharacterStyle.updateDrawState.

Базовым классом, который управляет расположением текста в визуальных элементах на экране, является android.text.Layout. Layout вместе с двумя его подклассами, StaticLayout и DynamicLayout, проверяют интервалы, заданные в тексте, для вычисления высоты строки и поля макета. Помимо этого, всякий раз, когда диапазон, отображаемый в DynamicLayout, обновляется, макет проверяет, является ли диапазон диапазоном UpdateLayout, и генерирует новый макет для затронутого текста.

Настройка текста для максимальной производительности

Есть несколько способов установки текста в TextView с эффективным использованием памяти, в зависимости от ваших потребностей.

1. Текст, установленный на TextView, никогда не меняется.

Если вы просто установите текст в TextView один раз и никогда не обновите его, вы можете просто создать новый экземпляр SpannableString или SpannableStringBuilder, установить необходимые промежутки и затем вызвать textView.setText(spannable). Поскольку вы больше не работаете с текстом, производительность больше не требуется.

2. Изменения стиля текста путем добавления / удаления промежутков.

Давайте рассмотрим случай, когда текст не меняется, но изменяются прикрепленные к нему интервалы. Например, предположим, что всякий раз, когда нажимается кнопка, вы хотите, чтобы слово в тексте становилось серым. Итак, нам нужно добавить к тексту новый диапазон. Для этого, скорее всего, у вас возникнет соблазн дважды позвонить textView.setText(CharSequence): сначала для установки исходного текста, а затем еще раз при нажатии кнопки. Более оптимальным решением было бы вызвать textView.setText(CharSequence, BufferType) и просто обновить интервалы объекта Spannable при нажатии кнопки.

Вот что происходит с каждым из этих вариантов:

Вариант 1. Вызов textView.setText (CharSequence) несколько раз - неоптимально

При вызове textView.setText(CharSequence) под капотом TextView создает копию вашего Spannable как SpannedString и сохраняет ее в памяти как aCharSequence. Следствием этого является то, что ваш текст и интервалы неизменны. Итак, когда вам нужно обновить текстовый стиль, вам нужно будет создать новый Spannable с текстом и промежутками, снова вызвать textView.setText, который, в свою очередь, создаст новую копию объекта.

Вариант 2. Вызов textView.setText (CharSequence, BufferType) один раз и обновление объекта Spannable - оптимальный вариант

При вызове textView.setText(CharSequence, BufferType) параметр BufferType сообщает TextView, какой тип текста установлен: статический (тип по умолчанию при вызове textView.setText(CharSequence)), стилизованный / составной текст или редактируемый (который будет использоваться EditText).

Поскольку мы работаем с текстом, который можно стилизовать, мы можем вызвать:

textView.setText(spannableObject, BufferType.SPANNABLE)

В этом случае TextView больше не создает SpannedString, но создаст SpannableString с помощью объекта-члена типа Spannable.Factory. Поэтому теперь копия CharSequence, хранящаяся в TextView, имеет изменяемую разметку и неизменяемый текст.

Чтобы обновить диапазон, мы сначала получаем текст как Spannable, а затем обновляем промежутки по мере необходимости.

// if setText was called with BufferType.SPANNABLE
textView.setText(spannable, BufferType.SPANNABLE)
// the text can be cast to Spannable
val spannableText = textView.text as Spannable
// now we can set or remove spans
spannableText.setSpan(
     ForegroundColorSpan(color), 
     8, spannableText.length, 
     SPAN_INCLUSIVE_INCLUSIVE)

С помощью этой опции мы создаем только начальный объект Spannable. TextView будет содержать его копию, но когда нам нужно ее изменить, нам не нужно создавать какие-либо другие объекты, потому что мы будем работать напрямую с экземпляром текста Spannable, который хранится в TextView. Однако TextView будет проинформирован только о добавлении / удалении / изменении положения пролетов. Если вы измените внутренний атрибут диапазона, вам нужно будет вызвать invalidate() или requestLayout(), в зависимости от типа изменения. См. Подробности в «Совете по бонусной производительности» ниже.

3. Изменения текста (повторное использование TextView)

Допустим, мы хотим повторно использовать TextView и задать текст несколько раз, как в RecyclerView.ViewHolder. По умолчанию, независимо от набора BufferType, TextView создает копию объекта CharSequence и сохраняет ее в памяти. Это гарантирует, что все TextView обновления являются преднамеренными, а не случайными, когда разработчик изменяет значение CharSequence по другим причинам.

В варианте 2 выше мы видели, что при установке текста с помощью textView.setText(spannableObject, BufferType.SPANNABLE) TextView копирует CharSequence, создавая новый SpannableString с помощью экземпляра Spannable.Factory. Итак, каждый раз, когда мы устанавливаем новый текст, он создает новый объект. Если вы хотите получить больший контроль над этим процессом и избежать создания дополнительных объектов, реализуйте свой собственный Spannable.Factory, переопределите newSpannable (CharSequence) и установите для фабрики значение TextView.

В нашей собственной реализации мы хотим избежать создания этого нового объекта, поэтому мы можем просто вернуть преобразование CharSequence как Spannable. Имейте в виду, что для этого необходимо вызвать textView.setText(spannableObject, BufferType.SPANNABLE), иначе источник CharSequence будет экземпляром Spanned, который не может быть преобразован в Spannable, что приведет к ClassCastException.

val spannableFactory = object : Spannable.Factory() {
    override fun newSpannable(source: CharSequence?): Spannable {
        return source as Spannable
    }
}

Установите объект Spannable.Factory один раз сразу после того, как получите ссылку на свой TextView. Если вы используете RecyclerView, сделайте это, когда впервые увеличите количество просмотров.

textView.setSpannableFactory(spannableFactory)

Благодаря этому вы избегаете создания лишних объектов каждый раз, когда RecyclerView привязывает новый элемент к ViewHolder.

Чтобы добиться еще большей производительности при работе с текстом и RecyclerViews, вместо создания объекта Spannable из String в ViewHolder сделайте это перед передачей списка в Adapter. Это позволяет вам создавать Spannable объекты в фоновом потоке вместе с любой другой работой, которую вы выполняете с элементами списка. Тогда ваш Adapter может сохранить ссылку на List<Spannable>.

Совет по бонусной производительности

Если вам нужно изменить только внутренний атрибут диапазона (например, цвет маркера для настраиваемого диапазона маркеров), вам не нужно снова вызывать TextView.setText, а просто invalidate() или requestLayout(). Повторный вызов setText приведет к запуску ненужной логики и созданию объектов, когда представление нужно просто перерисовать или повторно измерить.

Что вам нужно сделать, так это сохранить ссылку на изменяемый диапазон и, в зависимости от того, какое свойство вы изменили в представлении, вызвать:

  • TextView.invalidate() если вы просто меняете внешний вид текста, чтобы вызвать перерисовку и пропустить повторную раскладку.
  • TextView.requestLayout(), если вы внесли изменение, которое влияет на размер текста, чтобы вид мог позаботиться об измерении, компоновке и рисовании.

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

class MainActivity : AppCompatActivity() {
    // keeping the span as a field
    val bulletSpan = BulletPointSpan(color = Color.RED)
    override fun onCreate(savedInstanceState: Bundle?) {
        …
        val spannable = SpannableString(“Text is spantastic”)
        // setting the span to the bulletSpan field
        spannable.setSpan(
            bulletSpan, 
            0, 4, 
            Spanned.SPAN_INCLUSIVE_INCLUSIVE)
        styledText.setText(spannable)
        button.setOnClickListener( {
            // change the color of our mutable span
            bulletSpan.color = Color.GRAY
            // color won’t be changed until invalidate is called
            styledText.invalidate()
        }
    }

Под капотом: передача текста с интервалами внутри и между процессами

TL;DR:

Настраиваемые атрибуты диапазона не будут использоваться, когда составные объекты передаются внутри или между процессами. Если желаемый стиль может быть достигнут только с помощью промежутков фреймворка, предпочтительнее применять несколько промежутков фреймворка реализации собственных промежутков. В противном случае предпочтите реализацию настраиваемых диапазонов, расширяющих некоторые из базовых интерфейсов или абстрактных классов.

В Android текст может передаваться в одном и том же процессе (внутри процесса), например, из одного действия в другое через намерения, а также между процессами (между процессами), когда текст копируется из одного приложения в другое.

Реализации пользовательского диапазона не могут переходить границы процессов, поскольку другие процессы не знают о них и не знают, как с ними обращаться. Интервалы фреймворка Android являются глобальными объектами, но внутри и между процессами можно передавать только те диапазоны, которые простираются от ParcelableSpan. Эта функция позволяет разделять и распаковывать все свойства диапазона, определенного в фреймворке. Метод TextUtils.writeToParcel отвечает за сохранение информации о промежутках в Parcel.

Например, вы можете передавать интервалы в том же процессе между Activities с помощью намерения:

// start Activity with text with spans
val intent = Intent(this, MainActivity::class.java)
intent.putExtra(TEXT_EXTRA, mySpannableString)
startActivity(intent)
// read text with Spans
val intentCharSequence = intent.getCharSequenceExtra(TEXT_EXTRA)

Таким образом, даже если вы передаете промежутки в одном и том же процессе, только структура ParcelableSpans выживает при прохождении через намерение.

ParcelableSpans также позволяет копировать текст вместе с промежутками от одного процесса к другому. Процесс копирования и вставки текста проходит через ClipboardService, который под капотом использует тот же метод TextUtil.writeToParcel. Таким образом, даже если вы копируете промежутки из своего приложения и вставляете их в одно и то же приложение, это межпроцессное действие и требует разделения, поскольку текст проходит через ClipboardService.

По умолчанию любой класс, реализующий Parcelable, может быть записан и восстановлен из Parcel. При передаче объекта Parcelable между процессами единственными классами, которые гарантированно восстанавливаются правильно, являются классы каркаса. Если процесс, который пытается восстановить данные из Parcel, не может создать объект, потому что тип данных определен в другом приложении, процесс завершится аварийно.

Здесь есть два больших предостережения:

  1. Когда текст с диапазоном передается либо в том же процессе, либо между процессами, сохраняются только ParcelableSpans ссылки на фреймворк. Как следствие, стили нестандартных пролетов не распространяются.
  2. Вы не можете создать свой собственный ParcelableSpan. Чтобы избежать сбоев из-за неизвестных типов данных, фреймворк не позволяет реализовать пользовательский ParcelableSpan, определяя два метода, getSpanTypeIdInternal и writeToParcelInternal, как скрытые. Оба они используются TextUtils.writeToParcel.

Предположим, вы хотите определить диапазон маркеров, который позволяет настраивать размер маркера, поскольку существующий BulletSpan определяет фиксированный размер радиуса 4 пикселя. Вот как это можно реализовать и каковы последствия каждого из них:

  1. Создайте CustomBulletSpan, который расширяет BulletSpan, но также позволяет установить параметр для размера маркера. Когда диапазон передается либо от одного действия к другому, либо путем копирования текста, диапазон, прикрепленный к тексту, будет BulletSpan. Это означает, что при отрисовке текста он будет иметь радиус маркера по умолчанию, а не установленный в CustomBulletSpan.
  2. Создайте CustomBulletSpan, который просто является продолжением LeadingMarginSpan и повторно реализует функциональность маркированного списка. Когда диапазон передается либо от одного Activity к другому, либо путем копирования текста, диапазон, прикрепленный к тексту, будет LeadingMarginSpan. Это означает, что когда текст будет нарисован, он потеряет все стили.

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

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

Работа с текстом в Android - настолько распространенная задача, что вызов правильного TextView.setText метода может помочь вам уменьшить использование памяти вашим приложением и повысить его производительность.

Большое спасибо Сиямед Синир, Кларе Баярри и Нику Бутчеру.