查看原文
其他

【Android】谷歌为什么不帮我默认实现啊,ImageGetter 和 TagHandler 的作用与区别

鸿洋
2024-12-14

The following article is from Android补给站 Author Newki

本文作者


作者:Newki

链接:

https://juejin.cn/post/7413274784484130828

本文由作者授权发布。


因为本文是手机发布的,所以随便找了张自己拍的照片当封面。


前言

在 Android 开发中,不管是详情的全屏图文混排,还是文本带小图片小标签的展示,只要涉及到图文混排我们可以用三种方案来实现,drawable,spannable,html 显示。
为了兼容前后端,兼容其他端,我们最常用的肯定是用 html 的方式显示的兼容性最好,但是为什么后端返回的富文本的 html 在 iOS 上能正常显示,在 Android 上显示不了啊?啊?
fromHtml(String source, ImageGetter imageGetter,TagHandler tagHandler)

是这么用的啊,凭什么 iOS 直接加载一个字符串就能显示,我们 Android 还得用 ImageGetter 和 TagHandler 的参数,这都是啥啊,为什么要整的这么复杂?

1ImageGetter 和 TagHandler 的作用

其实很简单,ImageGetter 用于加载与处理我们 html 文本中的标签。
为什么 Android 不默认帮我们实现加载图片,因为谷歌不知道我们要怎么加载图片,就像我们开发中的 ImageView 加载图片我们也是会用不同的图片加载框架去实现,谷歌也不知道我们要怎么实现。
并且图片有多种图片类型,base64 url 本地图片,甚至还有 webp gif 的图片加载起来更复杂,谷歌也做不到自动加载,给一个钩子让我们自行实现,自动控制大小位置等属性,其实相比起来会更加的灵活。
除了灵活性之外其次就是考虑到安全性和性能的因素了,自动加载和显示标签中的图像可能会带来安全隐患。恶意攻击者可能会利用 标签加载恶意内容或进行网络钓鱼攻击。通过手动实现 Html.ImageGetter,开发者可以对图像加载过程进行控制,检查和过滤不可信的内容,从而减少安全风险。
默认情况下,Android 的 TextView 不会自动处理图像的加载,以避免阻塞主线程和影响性能。手动实现 ImageGetter 允许开发者在后台线程中异步加载图像,并进行优化,如缓存图像、调整图像大小等,从而提高应用的性能。
关于 TagHandler 用于处理 Android 不支持的标签和一些自定义标签的。
众所周知,并不是所有的 HTML 标签在 TextView 中都是支持的,且官方文档并没有明确的说明支持 HTML 标签列表,通过查看 Android 源代码,可以得到简单的支持列表。
<br>,< p>,< div align=>,< strong><b><em><cite><dfn><i><big><small><font size=><font color=><blockquote><tt><a href=>,
<u><sup><sub><h1>,<h2>,<h3>,<h4>,<h5>,<h6><img src=><strike>
其实谷歌也是支持默认的最基本的文本显示,如果要高级的标签就需要我们自己处理,也是基于安全与性能的考虑。
支持的标签集有限可以减少潜在的安全风险。某些 HTML 标签可能包含恶意代码或脚本(如
处理和渲染 HTML 内容需要计算资源。提供一个有限的标签集可以确保 TextView 的性能保持在可接受的范围内。某些标签可能需要复杂的布局和渲染逻辑,支持它们会显著增加 TextView 的复杂性和性能开销。支持所有 HTML 标签会使控件变得过于复杂,也会增加维护成本。通过限制支持的标签集,Android 可以保持 TextView 简单、稳定且易于维护。

并且在 TagHandler 的钩子中我们可以自行解析实现一些标签甚至是自定义标签,实现特殊的效果,更加的灵活。(类似的解析框架有很多)

2如何加载Hmtl图片,ImageGetter的使用

由于系统并不知道我们加载的是什么类型的图片,所以我们需要处理不同的类型,比如比较简单的 base64 的图片,这在富文本编辑器中很常见。我们只需要很简单的处理就能显示图片:
private void setHtmlWithImages(TextView textView, String htmlContent) {
        Html.ImageGetter imageGetter = source -> {
            try {
                // 仅当 source 包含 base64 编码时才进行处理
                if (source.contains("base64,")) {
                    byte[] decodedString = Base64.decode(source.substring(source.indexOf(",") + 1), Base64.DEFAULT);
                    Bitmap bitmap = BitmapFactory.decodeStream(new ByteArrayInputStream(decodedString));

                    if (bitmap == null) {
                        Log.e("ImageGetter""Failed to decode base64 image");
                    } else {
                        Log.i("ImageGetter""Decoded image successfully");
                    }

                    return new BitmapDrawable(textView.getResources(), bitmap) {
                        @Override
                        public void draw(Canvas canvas) {
                            setBounds(00, bitmap.getWidth(), bitmap.getHeight());
                            super.draw(canvas);
                        }
                    };
                } else {
                    Log.e("ImageGetter""Image source does not contain base64");
                    return null;
                }
            } catch (Exception e) {
                Log.e("ImageGetter""Error decoding image", e);
                return null;
            }
        };


        Spanned spanned = Html.fromHtml(htmlContent, imageGetter, null);
        textView.setText(spanned);
    }

如果是加载网络 URL 图片呢,那选择更多了,比如我们使用 OkHttp 来获取图片数据,并使用 RxJava 来处理异步操作:

public void setHtmlWithImages(TextView textView, String htmlContent) {
    Html.ImageGetter imageGetter = source -> {
        try {
            // 同步请求图片
            OkHttpClient client = new OkHttpClient();
            Request request = new Request.Builder().url(source).build();
            Response response = client.newCall(request).execute();

            if (!response.isSuccessful()) {
                Log.e("ImageGetter""Failed to download image: " + response);
                return null;
            }

            byte[] bytes = response.body().bytes();
            Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);

            if (bitmap != null) {
                return new BitmapDrawable(textView.getResources(), bitmap);
            } else {
                Log.e("ImageGetter""Failed to decode image");
                return null;
            }
        } catch (Exception e) {
            Log.e("ImageGetter""Error loading image", e);
            return null;
        }
    };

    Observable.create((Observable.OnSubscribe<Spanned>) subscriber -> {
        Spanned spanned = Html.fromHtml(htmlContent, imageGetter, null);
        subscriber.onNext(spanned);
        subscriber.onCompleted();
    })
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(new Observer<Spanned>() {
        @Override
        public void onNext(Spanned spanned) {
            textView.setText(spanned);
        }

        @Override
        public void onCompleted() {}

        @Override
        public void onError(Throwable e) {
            Log.e("setHtmlWithImages""Error setting HTML with images", e);
        }
    });
}
但是图片如果比较多比较大,我们还需要处理缓存和压缩,我们是不是可以直接用图片框架来实现?比如Glide。
public void setHtmlWithImages(TextView textView, String htmlContent) {
    Html.ImageGetter imageGetter = source -> {
        // 创建一个 drawable 占位符
        BitmapDrawable placeholder = new BitmapDrawable(textView.getResources());

        // 使用 Glide 异步加载图片
        Glide.with(textView.getContext())
                .asBitmap()
                .load(source)
                .into(new CustomTarget<Bitmap>() {
                    @Override
                    public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
                        BitmapDrawable drawable = new BitmapDrawable(textView.getResources(), resource);
                        drawable.setBounds(00, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
                        textView.setText(Html.fromHtml(htmlContent, source1 -> drawable, null));
                    }

                    @Override
                    public void onLoadCleared(@Nullable Drawable placeholder) {
                        // 可选:处理加载取消时的占位符
                    }
                });

        return placeholder;
    };

    // 初始设置文本,以便开始加载图片
    textView.setText(Html.fromHtml(htmlContent, imageGetter, null));
}
Glide 自动在后台线程加载图片,并在加载完成后切换到主线程更新 UI,并且自动进行内存和磁盘缓存,提高加载效率。
甚至是 gif 图片我们也能自定义解析,比如使用第三方的 android-gif-drawable 来加载 gif 图片。
public void setHtmlWithGif(TextView textView, String htmlContent) {
    Html.ImageGetter imageGetter = source -> {
        try {
            URL url = new URL(source);
            HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
            connection.connect();
            GifDrawable gifDrawable = new GifDrawable(connection.getInputStream());

            gifDrawable.setBounds(00, gifDrawable.getIntrinsicWidth(), gifDrawable.getIntrinsicHeight());
            return gifDrawable;
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    };

    textView.setText(Html.fromHtml(htmlContent, imageGetter, null));
}
现在你知道谷歌为什么不帮我们默认实现了吧,因为能实现的方式太多了。
3其他标签的支持,自定义 TagHandler

由于 Android 的 TextView 只支持基本的标签,对于一些排版的标签如 <ul>, <ol>, <li>, <center> 等标签并不支持,如果我们要自己实现,就得通过 TagHandler 自己实现了,比如:
  private static class CustomTagHandler implements Html.TagHandler {
        private static final String UL_TAG = "ul";
        private static final String OL_TAG = "ol";
        private static final String LI_TAG = "li";
        private static final String CODE_TAG = "code";
        private static final String CENTER_TAG = "center";
        private static final String STRIKE_TAG = "strike";

        private int olIndex = 0;

        @Override
        public void handleTag(boolean opening, String tag, Spanned output, Html.TagHandler.StartEnd startEnd, boolean isEmpty) {
            if (tag.equalsIgnoreCase(UL_TAG) || tag.equalsIgnoreCase(OL_TAG)) {
                if (opening) {
                    olIndex = 0;
                }
            } else if (tag.equalsIgnoreCase(LI_TAG)) {
                if (opening) {
                    int start = output.length();
                    if (tag.equalsIgnoreCase(OL_TAG)) {
                        olIndex++;
                        output.insert(start, olIndex + ". ");
                    }
                    output.setSpan(new BulletSpan(20), start, start, Spanned.SPAN_MARK_MARK);
                } else {
                    int end = output.length();
                    BulletSpan[] spans = output.getSpans(0end, BulletSpan.class);
                    if (spans.length > 0) {
                        int start = output.getSpanStart(spans[spans.length - 1]);
                        output.setSpan(spans[spans.length - 1], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                    }
                }
            } else if (tag.equalsIgnoreCase(CODE_TAG)) {
                if (opening) {
                    int start = output.length();
                    output.setSpan(new TypefaceSpan("monospace"), start, start, Spanned.SPAN_MARK_MARK);
                } else {
                    int end = output.length();
                    TypefaceSpan[] spans = output.getSpans(0end, TypefaceSpan.class);
                    if (spans.length > 0) {
                        int start = output.getSpanStart(spans[spans.length - 1]);
                        output.setSpan(spans[spans.length - 1], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                    }
                }
            } else if (tag.equalsIgnoreCase(CENTER_TAG)) {
                if (opening) {
                    int start = output.length();
                    output.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), start, start, Spanned.SPAN_MARK_MARK);
                } else {
                    int end = output.length();
                    AlignmentSpan[] spans = output.getSpans(0end, AlignmentSpan.class);
                    if (spans.length > 0) {
                        int start = output.getSpanStart(spans[spans.length - 1]);
                        output.setSpan(spans[spans.length - 1], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                    }
                }
            } else if (tag.equalsIgnoreCase(STRIKE_TAG)) {
                if (opening) {
                    int start = output.length();
                    output.setSpan(new StrikethroughSpan(), start, start, Spanned.SPAN_MARK_MARK);
                } else {
                    int end = output.length();
                    StrikethroughSpan[] spans = output.getSpans(0end, StrikethroughSpan.class);
                    if (spans.length > 0) {
                        int start = output.getSpanStart(spans[spans.length - 1]);
                        output.setSpan(spans[spans.length - 1], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                    }
                }
            }
        }
    }
我们甚至可以实现自定义的标签比如 custom 标签:
private static class CustomTagHandler implements Html.TagHandler {
        private static final String CUSTOM_TAG = "custom";

        @Override
        public void handleTag(boolean opening, String tag, Spanned output, Html.TagHandler.StartEnd startEnd, boolean isEmpty) {
            if (tag.equalsIgnoreCase(CUSTOM_TAG)) {
                if (opening) {
                    startHandleTag(output);
                } else {
                    endHandleTag(output);
                }
            }
        }

        private void startHandleTag(Spanned output) {
            int length = output.length();
            output.setSpan(new ForegroundColorSpan(0xFFFF0000), length, length, Spanned.SPAN_MARK_MARK);
        }

        private void endHandleTag(Spanned output) {
            int length = output.length();
            ForegroundColorSpan[] spans = output.getSpans(0, length, ForegroundColorSpan.class);
            if (spans.length > 0) {
                int start = output.getSpanStart(spans[spans.length - 1]);
                output.setSpan(spans[spans.length - 1], start, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }
    }
这样就能实现背景的效果,本质上是把自定义标签转为 Span 了,我们知道 Span 也是可以自定义的,那么我们是不是可以通过 自定义的标签实现自定义的 Span 呢?当然也是可以的,灵活性极高。
比如我们通过自定义的标签实现自定义的字体替换:
public class MyTypefaceSpan extends MetricAffectingSpan {

    private final Typeface typeface;

    public MyTypefaceSpan(final Typeface typeface) {
        this.typeface = typeface;
    }

    @Override
    public void updateDrawState(final TextPaint drawState) {
        apply(drawState);
    }

    @Override
    public void updateMeasureState(final TextPaint paint) {
        apply(paint);
    }

    private void apply(final Paint paint) {
        final Typeface oldTypeface = paint.getTypeface();
        final int oldStyle = oldTypeface != null ? oldTypeface.getStyle() : 0;
        int fakeStyle = oldStyle & ~typeface.getStyle();
        if ((fakeStyle & Typeface.BOLD) != 0) {
            paint.setFakeBoldText(true);
        }
        if ((fakeStyle & Typeface.ITALIC) != 0) {
            paint.setTextSkewX(-0.25f);
        }
        paint.setTypeface(typeface);
    }

}


public class TypeFaceLabel implements Html.TagHandler {
    private Typeface typeface;
    private int startIndex = 0;
    private int stopIndex = 0;

    public TypeFaceLabel(Typeface typeface) {
        this.typeface = typeface;
    }

    @Override
    public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
        if (tag.toLowerCase().equals("face")) {
            if (opening) {
                startIndex = output.length();
            } else {
                stopIndex = output.length();
                //使用的是自定义的字体来实现
                output.setSpan(new MyTypefaceSpan(typeface), startIndex, stopIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }
    }

}

我们定义 string 资源:

    <string name="hr_view_resume"><![CDATA[<font color="#000000">HR from</font>
    <face><font color="#0689FB"> %s </font></face>
    <font color="#000000"> has viewed your resume.</font>
     ]]></string>
使用:
  String content = String.format(mContext.getString(R.string.hr_view_resume), item.employer_name);

    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
        tv_resume_log_content.setText(Html.fromHtml(content, Html.FROM_HTML_MODE_LEGACY, nullnew TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
    } else {
        tv_resume_log_content.setText(Html.fromHtml(content, nullnew TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
    }
效果:

4总结


本文介绍了 ImageGetter 和 TagHandler 的作用,以及具体的实现和一些扩展,在图文混排 html 方案中确实实用。
虽然谷歌没有给我们默认实现,但是提供了更加灵活的方式,我更喜欢。

其实关于 ImageGetter 与 TagHandler 的使用,以后有大佬开源了很棒的方案,比如 EasyImageGetter 和 HtmlTextView 他们都是很棒的开源库。

https://github.com/easyandroidgroup/EasyAndroid/blob/master/utils/src/main/java/com/haoge/easyandroid/easy/EasyImageGetter.kt

https://github.com/SufficientlySecure/html-textview


但是但是,它们都已经四五年没更新了,我们需要了解他们的原理,理解了原理之后再看他们的开源库,其实就很好理解了,如果有什么版本兼容的问题或者有自定义的需求也能很快速的定位。
毕竟移动端式微,很多的开源都已经停更了,也就对我们现在的开发者有更多的挑战,我们使用一个库或框架的时候最好是了解其实现原理,万一停更或者有问题我们才能有解决方案。
如果是项目中一些小的需求不想导入第三方的库,或者公司有限制不让随便导入第三方的库,那么我们自己实现照着这个思路其实也是非常的简单的。

本文的代码在文中已经全部给出,如果要要扩展实现其实也不难,相信大家都能轻松的完成啦。

那么今天的分享就到这里了,当然如果你有其他的更多的更好的实现方式,也希望大家能评论区交流一起学习进步。如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。

如果感觉本文对你有一点的启发和帮助,还望你能点赞支持一下,你的支持对我真的很重要。

Ok,这一期就此完结。‍




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存