专栏名称: 鸿洋
你好,欢迎关注鸿洋的公众号,每天为您推送高质量文章,让你每天都能涨知识。点击历史消息,查看所有已推送的文章,喜欢可以置顶本公众号。此外,本公众号支持投稿,如果你有原创的文章,希望通过本公众号发布,欢迎投稿。
目录
相关文章推荐
stormzhang  ·  打工人何苦为难打工人 ·  14 小时前  
鸿洋  ·  好用的HarmonyOS Next ... ·  昨天  
鸿洋  ·  HarmonyOS NEXT启程,送10本入门书籍 ·  2 天前  
51好读  ›  专栏  ›  鸿洋

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

鸿洋  · 公众号  · android  · 2024-10-23 08:35

正文

本文作者


作者: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 的参数,这都是啥啊,为什么要整的这么复杂?

1
ImageGetter 和 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) subscriber -> {
        Spanned spanned = Html.fromHtml(htmlContent, imageGetter, null);
        subscriber.onNext(spanned);
        subscriber.onCompleted();
    })
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(new Observer() {
        @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() {
                    @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 只支持基本的标签,对于一些排版的标签如 
    , <
ol>,
  • ,
  •  等标签并不支持,如果我们要自己实现,就得通过 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">HR from
         %s 
         has viewed your resume.
         ]]>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^)┛明天见!