作者: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 的参数,这都是啥啊,为什么要整的这么复杂?
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 的钩子中我们可以自行解析实现一些标签甚至是自定义标签,实现特殊的效果,更加的灵活。(类似的解析框架有很多)
如何加载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(0, 0, 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(0, 0, 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(0, 0, gifDrawable.getIntrinsicWidth(), gifDrawable.getIntrinsicHeight());
return gifDrawable;
} catch (IOException e) {
e.printStackTrace();
return null;
}
};
textView.setText(Html.fromHtml(htmlContent, imageGetter, null));
}
现在你知道谷歌为什么不帮我们默认实现了吧,因为能实现的方式太多了。
由于 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(0, end, 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(0, end, 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(0, end, 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(0, end, 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, null, new TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
} else {
tv_resume_log_content.setText(Html.fromHtml(content, null, new TypeFaceLabel(TypefaceUtil.getSFSemobold(mContext))));
}
本文介绍了 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^)┛明天见!