Тормозит анимация gif смайлов в TextView

Рейтинг: 1Ответов: 2Опубликовано: 17.04.2023

Приложение: аналог форума. В списке отображаются посты участников (html), текст с изображениями, в том числе и gif.

В обычном адаптере для recyclerview, через HtmlCompat.fromHtml загружаю html с тегами img, в которых посредством Glide загружаю gif смайлы из локальных ресурсов. Проблема в том, что при отображении текста со смайлами в TextView gif анимация сильно тормозит. Что-бы обойти эту проблему, указываю tv_text.setLayerType(View.LAYER_TYPE_SOFTWARE, null); Либо через xml разметку android:layerType="software". Анимация становится нормальной, однако появляется новая проблема: если в TextView очень много текста - он перестает отображаться.

Есть вариант: указать минимальный и максимальный размер шрифта в TextView, но длинные посты из-за этого превращаются в нечитаемый текст (размер шрифта автоматически уменьшается до минимума). Отключение аппаратного ускорения решает проблему анимации gif, но от этого страдает общая плавность работы приложения.

Нашел пост TextView with long text invisible with LAYER_TYPE_HARDWARE or LAYER_TYPE_SOFTWARE: вычисление ширины текста и сравнение с макс. шириной текстуры OpenGL. Этот пример так же не работает.

Пока нахожусь в поиске решений: обрезать текст, с добавление кнопки "раскрыть...", или найти другую реализацию GifDrawable.

public class GlideImageGetter implements Html.ImageGetter, Drawable.Callback {
    private final Resources resources;
    private final TextView textView;
    HashMap<String, String> icons;
    int width=-1;

    public GlideImageGetter(Resources resources, TextView tv_text, int width) {
        this.resources = resources;
        this.textView = tv_text;
        this.width = width;
        new GlideImageGetter(resources, tv_text);
    }

    public GlideImageGetter(Resources resources, TextView target) {
        super();
        this.resources = resources;
        this.textView = target;
        this.icons = Utils.getIcons();
    }

    CustomTarget<Drawable> customTarget(final FutureDrawable result){
        return new CustomTarget<Drawable>() {
            @Override
            public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {

                int maxWidth = 300;
                if (resource instanceof GifDrawable) {
                    GifDrawable gifDrawable = (GifDrawable) resource;

                    if (gifDrawable.getIntrinsicWidth() > maxWidth) {
                        float aspectRatio = (float) gifDrawable.getIntrinsicHeight() / (float) gifDrawable.getIntrinsicWidth();
                        gifDrawable.setBounds(0, 0, maxWidth, (int) (aspectRatio * maxWidth));
                    } else {
                        int width=(int) (gifDrawable.getIntrinsicWidth() * 2.5) ;
                        int height=(int) (gifDrawable.getIntrinsicHeight() * 2.5);
                        gifDrawable.setBounds(0, 0, width, height);
                    }

                    result.isGif=true;
                    result.setDrawable(gifDrawable);

                    //смайлы-гифки запускаем автоматически
                    if (result.isLocalImage) {
                        gifDrawable.setCallback(GlideImageGetter.this);
                        gifDrawable.start();
                    }

                } else {
                    if (resource.getIntrinsicWidth() > maxWidth) {
                        float aspectRatio = (float) resource.getIntrinsicHeight() / (float) resource.getIntrinsicWidth();
                        resource.setBounds(0, 0, maxWidth, (int) (aspectRatio * maxWidth));
                    } else {
                        resource.setBounds(0, 0, resource.getIntrinsicWidth(), resource.getIntrinsicHeight());
                    }
                    result.setDrawable(resource);
                }
            }

            @Override
            public void onLoadCleared(@Nullable Drawable placeholder) {
                if (null != placeholder) {
                    if (placeholder instanceof GifDrawable)
                        ((GifDrawable) placeholder).stop();
                }
            }
        };
    }

    @Override
    public Drawable getDrawable(String source) {
        if (source==null) return null;

        final FutureDrawable result = new FutureDrawable(resources);

        //загружаем "пустую картинку"
        Drawable empty = ContextCompat.getDrawable(textView.getContext(), R.drawable.ic_empty_image);
        if (this.width>0){
            empty = new ScaleDrawable(empty, 0, width, width).getDrawable();
            empty.setBounds(0, 0, this.width, this.width);
            result.setBounds(0, 0, this.width, this.width);
        } else
            result.setBounds(0, 0, empty.getIntrinsicWidth(), empty.getIntrinsicHeight());
        result.setDrawable(empty);

        final String imgsource;
        //поменялся путь к локальным смайлам форума
        if (source.contains("local=")) {
            String src=icons.get(source.replace("local=",""));
            imgsource = source.replace(source, "file:///android_asset/icons/"+src);
            result.isLocalImage=true;
        } else
            imgsource=source;

        if (!result.isLocalImage)
            if (VideoUtils.isVideoFast(source))
                result.isVideo=true;

        if (result.isLocalImage){
            GlideApp.with(textView)
                    .load(Uri.parse(imgsource))
                    .into(customTarget(result));
        } else
            GlideApp.with(textView)
                .asDrawable()
                .placeholder(empty)
                .error(R.drawable.ic_error_image)
                .load(Uri.parse(imgsource))
                .centerInside() //облегчаем
                .format(DecodeFormat.PREFER_RGB_565) //облегчаем
                .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
                .into(customTarget(result));

        return result;
    }

    @Override
    public void invalidateDrawable(@NonNull Drawable drawable) {\
        //textView.invalidate();
        textView.invalidate(drawable.getBounds());
    }

    @Override
    public void scheduleDrawable(@NonNull Drawable drawable, @NonNull Runnable runnable, long l) {
    }

    @Override
    public void unscheduleDrawable(@NonNull Drawable drawable, @NonNull Runnable runnable) {
    }

    public class FutureDrawable extends Drawable  {
        private Drawable drawable;
        public boolean isGif=false;
        public boolean isVideo=false;
        public boolean isLocalImage=false;
        Bitmap play;

        private Bitmap getBitmap(Drawable vectorDrawable) {
            Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(),
                    vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
            Canvas canvas = new Canvas(bitmap);
            vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
            vectorDrawable.draw(canvas);
            return bitmap;
        }

        @SuppressLint("UseCompatLoadingForDrawables")
        FutureDrawable(Resources res) {
            play = getBitmap(res.getDrawable(R.drawable.ic_play));
        }

        @Override
        public void draw(@NonNull Canvas canvas) {
            if(drawable != null) {
                drawable.draw(canvas);
                if (!isLocalImage && (isGif || isVideo)) {
                    //рисуем значок play поверх изображения

                    //если гифка запущена - не рисуем
                    if (this.drawable instanceof GifDrawable && ((GifDrawable) this.drawable).isRunning())
                        return;

                    //т.к. размер картинки может быть не пропорциональным
                    //берем размер например высоты
                    int width = getBounds().width() / 4;
                    int height = width;

                    if (width == 0 | height == 0) {
                        width = play.getWidth();
                        height = play.getHeight();
                    }

                    play = Bitmap.createScaledBitmap(play, width, height, false);

                    int x = (getBounds().width() - width) / 2;
                    int y = (getBounds().height() - height) / 2;
                    canvas.drawBitmap(play, x, y, new Paint(Paint.FILTER_BITMAP_FLAG));
                }
            }
        }

        public void play() {
            if (drawable != null) {
                if (isGif) {
                    GifDrawable gif=((GifDrawable) this.drawable);
                    if (gif.isRunning()) {
                        gif.stop();
                        gif.invalidateSelf();
                        gif.setCallback(null);
                        this.setDrawable(drawable); //draw() скрин с иконкой play
                    }else {
                        this.setDrawable(gif); //draw()
                        gif.setCallback(GlideImageGetter.this);
                        gif.start();
                    }
                }
            }
        }
        @NonNull
        @Override
        public Drawable getCurrent() {
            return drawable;
        }

        @Override
        public void setAlpha(int i) {
        }

        @Override
        public void setColorFilter(@Nullable ColorFilter colorFilter) {
        }

        @Override
        public int getOpacity() {
            return PixelFormat.UNKNOWN;
        }

        public void setDrawable(Drawable drawable){
            this.drawable=drawable;

            int drawableWidth = drawable.getIntrinsicWidth();
            int drawableHeight = drawable.getIntrinsicHeight();

            if (isGif){
                drawableWidth = (int) (drawable.getIntrinsicWidth() * 2.5);
                drawableHeight = (int) (drawable.getIntrinsicHeight()* 2.5);
            }

            int maxWidth = textView.getMeasuredWidth();
            if (drawableWidth > maxWidth) {
                int calculatedHeight = maxWidth * drawableHeight / drawableWidth;
                drawable.setBounds(0, 0, maxWidth, calculatedHeight);
                setBounds(0, 0, maxWidth, calculatedHeight);
            } else {
                drawable.setBounds(0, 0, drawableWidth, drawableHeight);
                setBounds(0, 0, drawableWidth, drawableHeight);
            }

            //для изменения размера картинок
            //textView.setText(textView.getText());
            textView.post(new Runnable() {
                @Override
                public void run() {
                    textView.setText(textView.getText());
                }
            });
        }

    }
}

В адаптере:

public class PostAdapter extends PagedListAdapter<PostView, RecyclerView.ViewHolder> {

    @Override
    public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {

        switch(getItemViewType(position)){
            case ITEM:
                final PostView post = getItem(position);
                final PostHolder postHolder=((PostHolder)holder);

                SpannableStringBuilder text=(SpannableStringBuilder)HtmlCompat.fromHtml(post.text, HtmlCompat.FROM_HTML_MODE_LEGACY, new GlideImageGetter(resources,postHolder.tv_text), new Html.TagHandler() {

                    @Override
                    public void handleTag(boolean opening, String tag, Editable editable, XMLReader xmlReader) {
                    }
                });

                postHolder.tv_text.setText(text);

В разметке:

<TextView
    android:id="@+id/tv_text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="4sp"
    android:textSize="18sp"
    tools:text="tv_text"
    />

Ответы

▲ 1Принят

Дополнено 2.

На официальном страничке https://github.com/bumptech/glide/issues/2471 предлагают откатиться на версию Glide 3.7.0, и установить у ImageView точные размеры android:layout_width, android:layout_height. Однако меня больше интересует анимация в HTML.

Нашел еще один вариант с плавной gif анимацией: выкидываем Glide, используем AnimationDrawable.

  1. Создаем AnimationDrawable
  2. Загружаем .gif файл, вытаскиваем каждый кадр с помощью GifDecoder (в интернете полно реализаций), и собственно добавляем эти кадры addFrame(drawable, decoder.getDelay(i));

Способы применения:

  1. Для ImageView просто присваиваем и анимация работает автоматически: imageView.setImageDrawable(gif);
  2. Для TextView необходимо:
  • создать новый класс AnimatedImageSpan расширяющий DynamicDrawableSpan
  • реализовать в новом классе пиналку (на основе Handler), которая заставит перерисовывать TextView
  • сформировать Spanned разметку, вставить туда наш новый класс AnimatedImageSpan
  • установить в качестве коллбэка TextView: gif3.setCallback(textview);
  1. Для преобразования HTML в текст с gif анимацией необходимо:
  • берем наш новый класс AnimatedImageSpan, и расширяем интерфейсами Runnable, Animatable, получаем MyGifDrawable
  • расширяем Html.ImageGetter, для gif изображений используем новый MyGifDrawable

Альтернативный второй вариант - расширить класс TextView (назовем его HTMLTextView), который при присвоении текста вытащит все AnimatedImageSpan, и самостоятельно установит коллбэк drawable.setCallback(HTMLTextView.this);. Что удобно - можно ставить анимацию на паузу и запускать когда угодно.

Анимация не тормозит в ImageView, TextView, RecyclerView.

Тестовый проект с превью: https://github.com/virex-84/GifTest

▲ 0

Дополнено.

Сразу после присвоения теста (postHolder.tv_text.setText(text)), попробовал рыть в сторону TextView with long text invisible with LAYER_TYPE_HARDWARE or LAYER_TYPE_SOFTWARE. Однако, хоть на эмуляторе такая конструкция работает, но на реальном устройстве нет:

int width = (int) tv_text.getPaint().measureText(tv_text.getText().toString());

int[] maxGlTexSize = new int[1];
GLES20.glGetIntegerv(GLES20.GL_MAX_TEXTURE_SIZE, maxGlTexSize, 0);
if (width < maxGlTexSize[0]) {
  //Log.e("Debug", "Too big for GL");
  tv_text.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
} else {
  //Log.i("Debug", "Small enough for GL");
  tv_text.setLayerType(View.LAYER_TYPE_HARDWARE, null);
}

Проблема (насколько я понял) в том, что мы не знаем, какая версия opengl используется для аппаратного ускорения. Можно получить максимальный размер текстуры и таким способом:

public static int getSupportedMaxPictureSize() {
    int[] array = new int[1];
    GLES10.glGetIntegerv(GLES10.GL_MAX_TEXTURE_SIZE, array, 0);

    try {
        if (array[0] == 0) {
            GLES11.glGetIntegerv(GLES11.GL_MAX_TEXTURE_SIZE, array, 0);

            if (array[0] == 0) {
                GLES20.glGetIntegerv(GLES20.GL_MAX_TEXTURE_SIZE, array, 0);

                if (array[0] == 0) {
                    GLES30.glGetIntegerv(GLES30.GL_MAX_TEXTURE_SIZE, array, 0);
                }
            }
        }
    } catch (NoClassDefFoundError e) {
        // Ignore the exception
    }

    return array[0] != 0 ? array[0] : 2048;
}

Но это тоже не поможет.

В итоге, обратил внимание что в логах появляется текст:

W/View: MaterialTextView not displayed because it is too large to fit into a software layer (or drawing cache), needs 10470408 bytes, only 10368000 available

В View buildDrawingCacheImpl как раз происходит вывод такого сообщения.

Поэтому, мы постараемся вычислить максимальный размер кэша каждого поста и отключим аппаратное ускорение, если размер нашего текста не помещается в текст. Устанавливаем в xml разметке для TextView android:layerType="none". И в адаптере пишем:

public class PostAdapter extends PagedListAdapter<PostView, RecyclerView.ViewHolder> {

    @Override
    public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {

        switch(getItemViewType(position)){
            case ITEM:
                final PostView post = getItem(position);
                final PostHolder postHolder=((PostHolder)holder);

                SpannableStringBuilder text=(SpannableStringBuilder)HtmlCompat.fromHtml(post.text, HtmlCompat.FROM_HTML_MODE_LEGACY, new GlideImageGetter(resources,postHolder.tv_text), new Html.TagHandler() {

                    @Override
                    public void handleTag(boolean opening, String tag, Editable editable, XMLReader xmlReader) {
                    }
                });

                postHolder.tv_text.setText(text);

                postHolder.tv_text.post(new Runnable() {
                  @Override
                  public void run() {
                    int width = tv_text.getMeasuredWidth();  //get width
                    int heigth = tv_text.getMeasuredHeight();  //get height
                    boolean opaque = tv_text.isOpaque();
                    boolean use32BitCache = false;

                    long max = ViewConfiguration.get(tv_text.getContext()).getScaledMaximumDrawingCacheSize();
                    long need = width * heigth * (opaque && !use32BitCache ? 2 : 4);

                    if (max > need) {
                      if (tv_text.getLayerType()!=View.LAYER_TYPE_SOFTWARE)
                        tv_text.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
                    } else {
                      if (tv_text.getLayerType()!=View.LAYER_TYPE_NONE)
                        tv_text.setLayerType(View.LAYER_TYPE_NONE, null);
                    }
                    }
                  });

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