好玩系列:让ImageSpan动起来
前言
前不久,我写过一篇文章:迟来的续集--Drawable+Animator,将优雅进行到底 , 并在其中留下一个思考题:" 用 动画Drawable 是否可以让 ImageSpan 直接动起来"
相信大家也进行了尝试,并且不出意外地出现了意外!即便使用可以动起来的Drawable构建ImageSpan,也没有让他动起来!
今天我们将在一个愉快的氛围下,让ImageSpan动起来,并进行一些更深层次的探索,不出意外,这将是Drawable相关文章的终结篇。
另外,有几篇相关文章可以作为扩展阅读:
是否用得上
"学而时习之,不亦说乎" -- 《论语-学而》
Span是Android中实现富文本的一种方式,读者诸君请注意,是一种方式而不是唯一方式! 除却Span机制,依旧有其他形式展现富文本。
但不可否认:Span是非常轻量的一种方式,虽然这种 脱离展示容器的轻量
使得它的设计并不简单,
导致了简单使用它时很方便,重度使用它时 难以如指臂使 ,并会遭遇性能瓶颈。
以Juejin为例,只要我持续创作读者感兴趣的内容,我相信终有一天会解锁Lv10级作者,那么:
"在APP上给我颁发一个会闪烁的徽章,并追加在昵称后,以显示身份",包括不限于:我的主页、文章作者栏、评论区、文章中 @我 的地方
这个功能似乎很合情合理😆。
用ImageSpan方案实现这一需求也很合理,并且这一解析、展示方案可以多处复用,并不需要四处精心维护布局。虽然juejin并未这样做😂
简单盘算后,今天的知识一定有使用的前景,稳赚不亏!
制造一个翻车现场
还是借助先前的项目:DrawableWorkShop ,先制作一个翻车现场。
在上一篇文章中,我们已经完成了动画Drawable,正好利用它生成ImageSpan。我们再增加DrawableStart 用作对比。
关键代码如下:
val tvSpan = findViewById<TextView>(R.id.tv_span)
val drawable = createADrawable()
val imgSpan = ImageSpan(drawable)
val ss = SpannableString("ImageSpan *")
ss.setSpan(imgSpan, 10, 11, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
tvSpan.text = ss
val drawableStart = createADrawable()
tvSpan.setCompoundDrawables(drawableStart, null, null, null)
tvSpan.setOnClickListener {
drawable.start()
drawableStart.start()
}
fun createADrawable(): AnimLetterDrawable2 {
val drawable = AnimLetterDrawable2()
drawable.textSize = 20f
drawable.letters = "span"
drawable.setBounds(0, 0, 100, 100)
return drawable
}
点击TextView开启动画,好,果然翻车了!ImageSpan并未动起来,而DrawableStart动起来了。
温故而知新 -- 原因分析
在前两篇相关文章中,我们已经窥探到动画的原理:
按照特定的时间序列绘制对应帧,利用视觉暂留形成动画效果
无论是控件的属性动画、还是Drawable动画,其本质均为此。在前两篇文章中,我们分别用了两种方式驱动Drawable形成动画效果,稍作复习:
- 基于
Drawable#scheduleSelf
API,向宿主ViewPost
一个延迟执行的Runnable
业务逻辑为重新绘制。在Handler消息机制的驱动下,Choreographer
实现了动画基本原理 - 基于
ValueAnimator
,按照时序执行回调,业务逻辑为重新绘制。依旧是借助Handler消息机制的驱动,Choreographer
实现动画基本原理。
此时,可以做出大胆的假设:问题本质是没有正确重新绘制。
在前文中,我们已经知道,Drawable重新绘制的核心是 invalidateSelf()
:
class Drawable {
public void invalidateSelf() {
final Callback callback = getCallback();
if (callback != null) {
callback.invalidateDrawable(this);
}
}
}
而该API借助了 Drawable.Callback
委托实现。
Debug之后可以发现,配合ImageSpan使用时,Drawable并未持有Callback实例 。
对比参考TextView设置DrawableStart的相关核心代码,忽略掉无关细节,其中调用了 Drawable#setCallback(this)
:
class TextView {
private void setRelativeDrawablesIfNeeded(Drawable start, Drawable end) {
boolean hasRelativeDrawables = (start != null) || (end != null);
if (hasRelativeDrawables) {
//ignore
if (start != null) {
//ignore
//重点
start.setCallback(this);
//ignore
} else {
dr.mDrawableSizeStart = dr.mDrawableHeightStart = 0;
}
if (end != null) {
//ignore
end.setCallback(this);
//ignore
} else {
dr.mDrawableSizeEnd = dr.mDrawableHeightEnd = 0;
}
//ignore
}
}
}
至此,推测得到验证。
直面问题 -- 以最简单的代码让ImageSpan动起来
才思敏捷的读者可能已经想到,给ImageSpan内部的Drawable设置Callback不就可以了吗?就像这样:
tvSpan.setOnClickListener {
//设置Callback
drawable.callback = it
drawable.start()
//屏蔽掉DrawableStart的干扰
// drawableStart.start()
}
当你自信满满的尝试了一下,哎呀,又TM翻车了!!!
翻车原因
让我们再复习一下:
public class View {
public void invalidateDrawable(@NonNull Drawable drawable) {
//看这里的校验
if (verifyDrawable(drawable)) {
final Rect dirty = drawable.getDirtyBounds();
final int scrollX = mScrollX;
final int scrollY = mScrollY;
//这里是刷新
invalidate(dirty.left + scrollX, dirty.top + scrollY,
dirty.right + scrollX, dirty.bottom + scrollY);
rebuildOutline();
}
}
protected boolean verifyDrawable(@NonNull Drawable who) {
// Avoid verifying the scroll bar drawable so that we don't end up in
// an invalidation loop. This effectively prevents the scroll bar
// drawable from triggering invalidations and scheduling runnables.
return who == mBackground || (mForegroundInfo != null && mForegroundInfo.mDrawable == who)
|| (mDefaultFocusHighlight == who);
}
}
很显然,ImageSpan内包含的Drawable和TextView之间并无直接关联!
注意,TextView的判断逻辑存在重载,仅背景、聚焦效果、DrawableStart、DrawableTop、DrawableEnd、DrawableBottom 是有关联的:
class TextView {
protected boolean verifyDrawable(@NonNull Drawable who) {
final boolean verified = super.verifyDrawable(who);
if (!verified && mDrawables != null) {
for (Drawable dr : mDrawables.mShowing) {
if (who == dr) {
return true;
}
}
}
return verified;
}
}
暗度陈仓,绕过校验
才思敏捷的读者朋友一定想到了:"既然是校验出的问题,那我绕过校验不就好了",很快掏出了代码V2:
tvSpan.setOnClickListener {
// drawable.callback = it //这种方式无效,Drawable和TextView之间无关联
drawable.callback = object : Drawable.Callback {
override fun invalidateDrawable(who: Drawable) {
//直接刷,绕过校验
it.invalidate()
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
it.scheduleDrawable(who, what, `when`)
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
it.unscheduleDrawable(who, what)
}
}
drawable.start()
// drawableStart.start()
}
果然,它动起来了!
作者按:让ImageSpan动起来的核心知识,到此已经结束,但知识的探索还未结束,下面将打开Span世界的大门
优雅,永恒的追求
我一直追求编程中的一种优雅:
- 复杂度按必要程度分层展开,避免 没有必要的详细导致难以理解的复杂
- 井然有序,当代码不得不做出改变时,不因代码间没必要的耦合,加大变化难度
而我们目前面对的问题,恰恰就是一个好机会,可以顺势研究源码,汲取知识,在此基础上,封装代码,更优雅地解决问题;并且在研究的过程中,可以摸索到其它知识模块。
这恰好可以通往第三境 对于
人生追求三境
,我会在下一个杂篇和读者们交流一下心得
直接使用会带来的问题
1.虽然有效,但耦合过重,业务代码中不得不暴露过多无关代码
我们已经使ImageSpan动起来了,那我多搞几个动态徽章没有问题吧。
将核心代码复刻,很快就得到了以下代码
val tvSpan2 = findViewById<TextView>(R.id.tv_span2)
val infoBuilder = SpannableStringBuilder().append("Leobert")
val madels = arrayListOf<String>("Lv.10", "持续创造", "笔耕不追", "夜以继日")
val drawables: List<AnimLetterDrawable2> = madels.map { madel ->
appendMadel(infoBuilder, madel).let { drawable ->
drawable.callback = object : Drawable.Callback {
override fun invalidateDrawable(who: Drawable) {
tvSpan2.invalidate()
}
//ignore
}
drawable
}
}
tvSpan2.text = infoBuilder
tvSpan2.setOnClickListener {
drawables.forEach {
it.start()
}
}
fun appendMadel(builder: SpannableStringBuilder, madel: String): AnimLetterDrawable2 {
val drawable = AnimLetterDrawable2()
//ignore
val imgSpan = ImageSpan(drawable)
val ss = SpannableString(" *")
ss.setSpan(imgSpan, 1, 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
builder.append(ss)
//ignore,追加了一处ClickSpan,可以直观观测点击触发
return drawable
}
您已经发现,为了让功能有效,我们不得不小心的设置回调,一旦有所疏漏,就会带来Bug。
务必注意,仅用于显示时,我们尚可以说服自己不负责任地忽略 "不移除回调" 带来的负面影响,诸如无效刷新、内存泄露。
而在编辑时(如EditText中使用,并删除图片)、以及RecycleView中TextView复用(推广到可替换呈现内容的情况) 则不得不移除回调。否则轻则造成性能损耗和内存泄露,重则立现 UI bug。
可以想象,这样的代码太过于丑陋,过多的无关代码暴露在业务实现中。
2.影响复用
Callback的唯一性导致ImageSpan不能被有效复用,还需要进行一定的改造。
虽然在理论上已经推断出这种做法会影响Drawable复用(以及Span的复用、进一步推广到Spannable的复用),仍旧以代码验证一下推论:
val tvSpan3 = findViewById<TextView>(R.id.tv_span3)
// 沿用上文中构建的Spannable,它已经通过Callback和tvSpan2高度耦合,
// 我们希望这样的代码就可以完成目标,但显然目前无法完成
tvSpan3.text = infoBuilder
tvSpan3.setOnClickListener {
drawables.forEach {
it.start()
}
}
tvSpan3.movementMethod = LinkMovementMethod.getInstance()
读者诸君可使用WorkShop自行尝试,不出意外的翻车了吧。
1.解决Callback对复用的限制
显然,我们无法修改SDK的内容,但可以使用组合模式。
先定义一个回调接口,依赖抽象以解耦。
interface OnRefreshListener {
/**
* will be called when a inner drawable of the span want to invalidate.
*
* @return true if the listener want to be called in future.
* false otherwise
*/
fun onRefresh(): Boolean
}
定义组合,仍旧以接口定义,以提升灵活度:
interface OnRefreshListeners : OnRefreshListener {
fun addRefreshListener(callback: OnRefreshListener)
fun removeRefreshListener(callback: OnRefreshListener)
}
我们可以顺其自然的定义一个组合Callback实现如下:
class DrawableCallbackComposer : OnRefreshListeners, Drawable.Callback {
private val mRefreshListeners: MutableCollection<OnRefreshListener> = mutableListOf()
override fun addRefreshListener(callback: OnRefreshListener) {
mRefreshListeners.add(callback)
}
override fun removeRefreshListener(callback: OnRefreshListener) {
mRefreshListeners.remove(callback)
}
override fun onRefresh(): Boolean {
val stillActivatedAfterRefresh = mRefreshListeners.filter {
it.onRefresh()
}
mRefreshListeners.clear()
mRefreshListeners.addAll(stillActivatedAfterRefresh)
return true
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
}
override fun invalidateDrawable(who: Drawable) {
onRefresh()
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
}
}
接下来就可以使用 DrawableCallbackComposer
来扩展复用度,但显然,我们并不愿意到处裸露 DrawableCallbackComposer
的操作,继续考虑封装和隐藏。
2.李代桃僵,利用代理
如果我们拥有一个代理层,它帮助 Drawable 处理 DrawableCallbackComposer
的 子节点 OnRefreshListener
注册与解注册,并且最终表现为一个 Drawable,那么 裸露的控制代码
将替换为简单的 依赖注入中的实例提供
基于接口的灵活性,我们可以顺其自然的定义如下 DrawableProxy
类。
它能够直接代理原Drawable关于绘制的全部内容,又追加了 使用 DrawableCallbackComposer 和 回调注册解注册的必要控制逻辑
class DrawableProxy
@JvmOverloads constructor(
proxy: Drawable? = null,
private val drawableCallbackComposer: DrawableCallbackComposer = DrawableCallbackComposer()
) : Drawable(),
ResizeDrawable,
Drawable.Callback by drawableCallbackComposer,
OnRefreshListeners by drawableCallbackComposer {
private var proxySafety: Drawable = proxy?.also { it.callback = drawableCallbackComposer } ?: this
set(drawable) {
field.callback = null
drawable.callback = drawableCallbackComposer
field = drawable
needResize = true
invalidateDrawable(this)
}
fun setProxy(drawable: Drawable) {
this.proxySafety = drawable
}
fun clearProxy() {
this.proxySafety = this
}
override var needResize: Boolean = false
/*以下是代理实现,无需过度关心*/
override fun getIntrinsicWidth(): Int {
return proxySafety.intrinsicWidth
}
override fun getIntrinsicHeight(): Int {
return proxySafety.intrinsicHeight
}
override fun draw(canvas: Canvas) {
proxySafety.draw(canvas)
}
override fun setAlpha(alpha: Int) {
proxySafety.alpha = alpha
}
override fun setColorFilter(cf: ColorFilter?) {
proxySafety.colorFilter = cf
}
override fun getOpacity(): Int {
return proxySafety.opacity
}
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
needResize = false
}
override fun setBounds(bounds: Rect) {
super.setBounds(bounds)
proxySafety.bounds = bounds
}
override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
super.setBounds(left, top, right, bottom)
proxySafety.setBounds(left, top, right, bottom)
}
}
至此,我们将使用DrawableProxy
替代原先的 AnimDrawable,用以构建Span实例。
接下来可以将注意力转移到:"实现、添加 OnRefreshListener",通过调用宿主TextView的刷新,显示动画帧。
继续使用SpanWatcher解放双手
行至此处,您一定不甘心再在业务代码中裸露此类控制代码:
drawable?.addRefreshListener(object : OnRefreshListener {
override fun onRefresh(): Boolean {
//ignore 诸如生命周期断定 。。。
view.invalidate()
return true
}
})
哪怕您将它封装成一个静态API以供业务代码中调用,也难以达到您对 追求"优雅" 的要求底线了。
而SDK中已经确定了这位天选打工人 SpanWatcher
,看一下它的定义:
/**
* When an object of this type is attached to a Spannable,
* its methods will be called to notify it that other
* markup objects have been added, changed, or removed.
* */
翻译如下:
当
SpanWatcher
类型的实例被添加到Spannable
之后,一旦发生 (Span)标记被 添加、改变、移除时, 实例相应的 API方法 会被调用,起到通知效果。
藉此,我们可以顺其自然地简化处理回调的注册与解注册
读者诸君,此时还请再想一想,它能完美的解决问题吗?
class AnimImageSpanWatcher(view: View) : SpanWatcher, OnRefreshListener {
private var mLastRefreshStamp: Long = 0
private val mViewWeakReference: WeakReference<View> = WeakReference(view)
override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) {
if (what is RefreshSpan) {
val drawable = what.getInvalidateDrawable()
drawable?.addRefreshListener(this)
}
}
override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) {
if (what is RefreshSpan) {
val drawable = what.getInvalidateDrawable()
drawable?.removeRefreshListener(this)
}
}
override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int, nend: Int) {
}
override fun onRefresh(): Boolean {
val view = mViewWeakReference.get() ?: return false
//ignore 生命周期有效性判断
val currentTime = System.currentTimeMillis()
//加一层过滤,避免刷新过于频繁
if (currentTime - mLastRefreshStamp > REFRESH_INTERVAL) {
mLastRefreshStamp = currentTime
view.invalidate()
}
return true
}
companion object {
private const val REFRESH_INTERVAL = 60
}
}
作者按:感谢 长安皈故里 的提醒,我遗漏了部分代码。
interface RefreshSpan {
fun getInvalidateDrawable(): OnRefreshListeners?
}
行至此处,还剩下关键的一步:借助TextView自身的机制,让这些类正常工作!
使用 Spannable.Factory
梦幻联动
不清楚读者诸君是否 精心研读 过 TextView
的源码,当然,本篇并不打算展开分析,TextView 中有一个API:
class TextView {
/**
* Sets the Factory used to create new Spannable
*/
public final void setSpannableFactory(Spannable.Factory factory) {
mSpannableFactory = factory;
setText(mText);
}
}
通过API实现,您可以发现,它会重新调用 setText API,并利用该 Factory 创建用于显示的 Spannable
Spannable.Factory
:
/**
* Factory used by TextView to create new Spannables.
* You can subclass it to provide something other than SpannableString.
*/
public static class Factory {
//ignore
/**
* Returns a new SpannableString from the specified CharSequence.
* You can override this to provide a different kind of Spannable.
*/
public Spannable newSpannable(CharSequence source) {
return new SpannableString(source);
}
}
值得注意:
设计Spannable的拷贝时,理所当然存在一些Span不希望被拷贝,于是设计有该机制,实现
NoCopySpan
的标记(span)不会被拷贝
于是,可实现类似如下工厂类,完成最后的联动。
class CustomSpannableFactory(private val mNoCopySpans: List<NoCopySpan>) : Spannable.Factory() {
override fun newSpannable(source: CharSequence): Spannable {
val spannableStringBuilder = SpannableStringBuilder()
mNoCopySpans.forEach {
spannableStringBuilder.setSpan(
it, 0, 0,
Spanned.SPAN_INCLUSIVE_INCLUSIVE or Spanned.SPAN_PRIORITY
)
}
spannableStringBuilder.append(source)
return spannableStringBuilder
}
}
最终,以伪代码实现展现调用细节如下:
val tvSpan = findViewById<TextView>(R.id.tv_span)
//第一部分
//如上文,创建一个动画Drawable
val drawable = createADrawable()
//基于代理,利用它简化Drawable.Callback的处理
val proxyDrawable = DrawableProxy(drawable)
//您可以认为这就是一个实现了RefreshSpan的ImageSpan,
//内部做了等高处理等,这些和文章主题无关,简要代码附于下文
val imgSpan = AnimIsohypseImageSpan(proxyDrawable)
//第二部分
//构建一个演示用的Spannable
val ss = SpannableString("ImageSpan *")
ss.setSpan(imgSpan, 10, 11, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
tvSpan.text = ss
//第三部分
//使用Watcher简化回调设置
val watchers = ArrayList<NoCopySpan>()
watchers.add(AnimImageSpanWatcher(tvSpan))
//使用自定义Factory实现联动
tvSpan.setSpannableFactory(CustomSpannableFactory(watchers))
//演示手动触发动画
tvSpan.setOnClickListener {
drawable.start()
}
作者按:感谢 长安皈故里 的提醒,我遗漏了部分代码。
另外还有一部分代码,仅用于参考,读者诸君需要结合自身项目实际情况进行类簇的扩展
//DrawableProxy中用到
interface ResizeDrawable {
var needResize: Boolean
}
interface IntegratedSpan
//等高
open class IsohypseImageSpan : ImageSpan, IntegratedSpan {
//构造器和等高计算、绘制等略
open fun getResizedDrawable(): Drawable {
val d = drawable
if (drawableHeight == 0) {
return d
}
if (!resized) {
resized = true
d.bounds = Rect(
0, 0,
(1f * drawableHeight.toFloat() * d.intrinsicWidth.toFloat() / d.intrinsicHeight).toInt(),
drawableHeight
)
}
return d
}
}
//配合自定义的SpanWatcher
open class AnimIsohypseImageSpan : IsohypseImageSpan, RefreshSpan {
//构造器略
override fun getInvalidateDrawable(): OnRefreshListeners? {
val d = getResizedDrawable()
return if (d is OnRefreshListeners) {
d
} else {
null
}
}
}
//用于有重计算高度的需求
class ResizeIsoheightImageSpan : AnimIsohypseImageSpan, RefreshSpan {
//构造器略
override fun getResizedDrawable(): Drawable {
val d = drawable
if (drawableHeight == 0) {
return d
}
if (d is ResizeDrawable && (d.needResize || !resized)) {
resizeSpan(d)
} else if (!resized) {
resizeSpan(d)
}
return d
}
private fun resizeSpan(d: Drawable) {
resized = true
d.bounds = Rect(0, 0,
(1f * drawableHeight * d.intrinsicWidth / d.intrinsicHeight).toInt(),
drawableHeight)
}
}
实际上,在工程中应用时:
- 第一部分我们会使用工厂类+依赖注入工具,在业务层屏蔽掉细节
- 第二部分略,如何构建Spannable并在业务层屏蔽该细节与本文无关
- 第三部分也可以屏蔽掉一些细节
可以想象的到,业务层变得纯粹了,而组件的 Ability 层(这是笔者自行杜撰的命名,用于描述自定义的控件、及其拓展功能的类)也会非常干净,均可以做到依赖抽象,按需使用,简化耦合。
敲响警钟
一般来说,文章写到这,基本是结束的节奏,可能您也憋了好长一口气才看到这里,想着可以缓一口气了。
然而有个坏消息不得不告诉您:其实问题并没有完全解决!
回顾一下前文,有这样一段:
务必注意,仅用于显示时,我们尚可以说服自己不负责任地忽略 "不移除回调" 带来的负面影响,诸如无效刷新、内存泄露。
而在编辑时(如EditText中使用,并删除图片)、以及RecycleView中TextView复用(推广到可替换呈现内容的情况) 则不得不移除回调。否则轻则造成性能损耗和内存泄露,重则立现 UI bug。
我们已经解决了一部分问题:
- 简化注册回调,让Spannable可以被多个View有效复用,展现出动画。并且在工程性问题上做的不错。
- 移除Span时,对回调进行解注册。可回看
AnimImageSpanWatcher
代码 - 使用者认为自身不再需要被回调更新时,解除回调。可回看
OnRefreshListener
代码,注意,该情况完全交给业务管控。 - 弱引用,不妨碍使用者及宿主Activity被回收
- 等
但有一个重要的问题:RecycleView中TextView复用(推广到可替换呈现内容的情况)没有考虑。
作者按:注意,EditText在编辑时的特性、选中特性、Span机制本身的特性问题,没有在本篇中展开。这不代表它们不存在!
持续思考&非常重要的结语
作者按:本篇的篇幅已经很长了,所以还请没有看过瘾的读者们见谅,考虑到大多数读者的阅读感受,我们需要在此结束了
但尚遗留3个问题需要持续思考:
- RecycleView中,控件复用了,问题该如何解决?
当然,我们可以简化问题模型,用 RecycleView中TextView复用
来表现太重了,我们可以简化为:
一个TextView展现 Spannable-A,随后展现 Spannable-B,
- 使用EditText编辑时,是否会有新的问题,例如:删除时先选中的需求,删除完之后,又应当如何解决
- 以上做法已经是最优了吗?还能不能继续优化
因为我认为以上代码尚存在优化空间,并且推测您的项目中已经使用了Span机制的扩展以实现某些需求;
而我尚不打算花费大量的时间编写用于兼容用途的功能类。所以我并未将这部分代码传入仓库,避免您直接当做库项目使用,导致原功能模块不可用。
如果您很迫切的需要将其移植到项目中,文中的代码已经涵盖所有重点,但请务必自行测试相关功能模块,做好兼容工作。