一种会话本地头像生成器
v1.0 1st Edition
这篇文章介绍一种会话头像的本地生成方式,主要的应用场景是类似IM(即时通信)会话列表。在实际开发中头像的生成依赖多个功能模块,这里也进行简单的设计方面介绍,如果对这方面有经验的朋友可直接跳转到TL;DR(太长不看),也可以直接从下面的列表跳转到本文的主题。
在正文开始之前首先阐述一下开发与设计层面的一些思想。
对客观事物的抽象与实现向来都是范围上由大到小,认知上由抽象到具体。
对程序设计来说,万万不能从细节着手拼凑出整体结构。实现不论复杂与否必须在设计上保持形式的简洁,以及细节的优雅,形式的简洁是一切的前提,细节的优雅则可以通过迭代与重构慢慢追求。
TL;DR 请点击列表跳转
前言
之前参与一个项目为其中添加一个即时通信功能,并且此功能是以插件的方式提供,即SDK。考虑到此功能复杂度较高,并且从设计角度来看其功能边界与主程序和各种第三方功能存在一定的覆盖,所以从一开始就尽量考虑到从集成到后续开发,以及使用上的诸多问题。这些问题如果随后有时间专门另起文章进行说明。
在我写完SDK底层类库后发现与用户直接交互的边界存在较多问题,其中一个就是会话列表在实现上并不简洁和优雅。这主要存在两个方面的问题,1、性能较低。2、没有功能上的隔离。于是我在SDK的基础上将与其相关的UI层下沉到一个UI SDK中,使得SDK功能实现与主程序隔离开。直接可感知被隔离的UI包括会话列表与消息列表,这也是SDK中较为复杂且要求较严格的地方,毕竟用户直接可以体验到。这里只探讨会话列表中头像的生成。
依赖
头像并不是凭空出现的,为了先获得头像必须先存在其他几个功能模块。比如:用户缓存系统、本地媒体管理系统、图片请求与缓存系统等。由于本文主要为了介绍头像生成,所以这些相关系统只做简单的描述。
以下以Android平台为例:
用户缓存
这是一个可选的多级用户缓存体系。SDK设计要给用户留下足够的可选择空间,在这里如果用户已经有自己的用户缓存,则这里的缓存仅仅做一个内外沟通的桥梁。
多级缓存
用户缓存在任何系统中都是必须的,但是不同的系统需要有选择的实现这一系统。而在移动端这一特殊的平台,更是需要从性能和效率等极端要求下更加细致的做设计与实现。而值得庆幸的是移动端作为一个单一用户的独立系统,其本身在数据和调用上的压力并不大,主要的问题还是在用户的可感知上的体验。
SDK中的用户缓存包括内存缓存和数据库缓存,数据库作为持久化存储目的就是尽量缓存更多的用户信息。内存缓存使用时间策略直接与UI进行交互,内存缓存未命中则去持久化数据中查找,持久化数据也未命中则向外部发送数据请求信息。外部系统为真实的数据来源,可能是本地数据也可能是立刻从服务器获取的数据。之后通过回调也好,事件分发也好通知UI刷新数据。
而对于Android平台来说,线程切换与Java语言自身的繁琐很多时候为代码实现带来很多困扰。在上图中一旦Memory
未命中就需要开启新线程做后续操作,因为这一段调用是在UI线程。而后续操作执行完成后除了要更新Memory
和Database
外,还要通知UI层做数据更新。可以是直接向UI层发送最新的数据,也可以是通知UI层再取一次。
缓存实现
说起缓存最直接也是最便捷的方法就是使用HashMap
,但是为了随后的需求我们要进行一定的封装。说到代码最重要的就是命名了,由于正好在吃梅子所以这个模块就命名为Berry
好了。
1 | public class BerryCache<K, V>{ |
如上代码片段为一个缓存类必须的接口,具体实现在一开始可以使用HashMap
或者LinkedHashMap
。不过作为一个比较完整的缓存实现,还需要一些有针对性的接口如“命中次数”、“未命中次数”、“动态分配容量”、“缓存快照”等等。当然一开始并不是都要实现,这些功能都是一个渐进式的开发过程,一开始可以不实现,但是一定要有。
1 | public class BerryUserInfoManager{ |
接着设计一个用户缓存的管理器,以后直接使用此对象查询用户信息。当然基本的getter/setter
是必须的,但是不要忘记的是这两个方式都是直接与userInfoCache:BerryCache
对象交互,而操作成功后再异步的使用databaseDao:BerryUserDatabaseDao
对象更新持久层。这些内部操作全部封装在BerryUserInfoManager
对象中,与外部的交互则需要在初始化中向其中注册BerryUserCacheListener
缓存状态监听器和BerryUserInfoProvider
用户信息提供者。
1 | public interface BerryUserCacheListener{ |
顾名思义这个接口的作用就是向外发送缓存更新事件,当然此回调是在后台线程background
中执行的。后续操作如何执行要看UI层使用哪种回调通知策略,不过目前来说比较好用的应该是EventBus
了吧。具体使用就是实现BerryUserCacheListener
,在BerryUserInfoManager
初始化时注册到进去。随后根据用户的具体更新详情可以向外发布用户更新消息,而当前进程中任何订阅了此事件的对象都可以获得此消息,并选择在哪个线程中做处理。
这里我不得不推荐一下EventBus
。它主要解决两个问题。
- 事件透传:从设计的角度很难完全控制到事件的传输通道,而这对具体开发真的非常重要。如果设计过多的事件传输通道,可能会造成过多对象的直接或间接引用,这在Android项目中有很大的潜在风险。
EventBus
采用事件订阅模式,这个思路非常简单,就是将需要获得事件的对象放在一起做统一管理,事件激发后就发送给这一系列对象。解决了事件源和观察与使用者的直接耦合,它相当于在逻辑实现外部新建了一个事件通道。当然如果有可能请从更高的视角做事件模型的封装. - 进程切换:Android的线程切换一直被人诟病,尤其是UI线程和后台线程的切换。正是由于这个操作非常繁琐,并且比较用以出错。所以不少开发者明知道在UI线程中直接执行IO操作存在一系列隐患,但是由于开始数据量不大等问题而有意的忽视。好在RxJava的出现从一定程度上缓解了此问题。RxJava在面对复杂操作时表现出的便捷性另人印象深刻,但是其本身的庞大与复杂(无论是使用思想还是使用方式)在很多简单的使用场景反而另人不喜欢。而
EventBus
采用订阅模式分发事件,并使用注解发方法在内部做线程切换。这对于事件接收者来说真的省了不少的心,另人非常愉快,同时它自身也比较小。
不过正是因为采用集中管理订阅者对象的方式,事件无法跨进程传输,这点需要特别注意。
1 | public interface BerryUserInfoProvider{ |
BerryUserInfoProvider
是向外部系统获取用户信息请求的接口,对外部系统来说其自身是用户信息的提供者,即Provider
。外部系统在使用此SDK时必须要实现用户信息的提供者,当然还有其它的比如图片请求框架的Provider
。此提供者的目的就是在缓存管理两级都未命中时向外调用getUserInfo(userId:String)
,请求成功后再更新两级缓存。
图片请求框架
图片请求框架分为:网络请求框架
和图片缓存框架
。
在移动端图片请求和缓存的重要性不言而喻,也正是应为此这类框架有很多的选择。我记得我刚接触Android开发的时候Android-Universal-Image-Loader
库几乎是大家的一致推荐,到现在我更加喜欢Glide
一些,也正因为此我模仿Glide
编写了一个图像框架的上层API封装。
对于这种选择性比较多且各有千秋的方案,最后提供一个抽象层隔离具体提方案和实现。对于图片框架这种抽象和隔离是非常必要的,这里我就提供一种简单的抽象方案,此方案便于
Glide
轻松切换。
这里使用类似于Strategy
的模式,尽量在使用上达到Glide
的效果。这里需要三个类ImageLoader
、Provider
、Strategy
。
如上图结构,核心类是BerryImageLoader
。在实际使用中所有操作使用BerryImageLoader#with(Context)
作为起点,然后使用链式调用最后使用get():Bitmap
或者into(IMageView)
结束。整个使用过程与Glide
非常类似,只需要将Glide#with(Context)
起点修改为BerryImageLoader
即可。
1 | public final class BerryImageLoader{ |
如上BerryImageLoader
代码片段,在初始化时使用外部实例化之后的BerryImageLoaderProvider
对象。这个类的主要作用是整个框架的入口,整个调用琏起始于#with(Context)
。但是真正的链式调用是BerryImageLoaderStrategy
中的内部类Builder
实现。另外BerryImageLoader
作为入口其本身相当于一个工具类,所以一些常见的Bitmap
操作会作为工具方法归于这个类中。
1 | public interface BerryImageLoaderProvider{ |
接口BerryImageLoaderProvider
实际上是整个抽象层的核心,其他所有结构都是为了构造一个BerryImageLoaderStrategy
对象然后传递到此接口的具体实现。Strategy
本质是承载所有参数的一个构造体,用于向下层具体图片处理框架提供足够的信息,比如Glide
。
1 | public class BerryImageLoaderStrategy { |
BerryImageLoaderStrategy
类的主要作用是参数收集,在默认的使用中也不会接触到这个类。其参数收集使用内部类作为Builder
模式实现类似于Glide
的链式调用。在参数收集完成之后调用get()
或者into()
方法同步或异步获取图片信息。
当然如果对于图片请求有特殊需求,如像Glide
一样的各种特殊配置,则需要扩展BerryImageLoaderStrategy
类。因为Glide
的链式调用实际上是多个步骤,多个对象相互切换的结果,并不是简单的参数收集。所以如果要完全封装一个图片框架,其工作量实际上非常的大,这方面请酌情处理。
下面简单的介绍一种基于Glide
的实现方式:
GlideImageLoaderProvider.java
1 | public class GlideImageLoaderProvider implements BerryImageLoaderProvider{ |
如上述代码片段为基于Glide
的一种实现。这里可以看出Provider
之所以要提供两个方法,主要就是提供一个异步和同步的图片请求方式,因此如果有更多需求则需要增加其他方法。
图像生成
就如前文所描述的那样,图片生成需要获得图片。在很多时候我们仅仅有几个必要的属性,比如userId
、userType
等。正是因为存在这种情况才需要一个根据这些字段获得用户详情的工具,也就是前文所说的用户缓存。
组合图像
一个非常好的系统应该在设计上保持简洁,实现形式上保持简单。上文说优雅实际上或多或少包括代码风格,因为有些代码虽然功能是实现了但是未来肯定会出问题。比如最初项目的会话头像生成是用UI层堆叠而成,且不说自定义控件是否足够复杂,至少我是觉得自定义控件比简单的堆叠UI好很多。但是我想说的是让UI层完成这个功能完全没有灵活性,并且逻辑上也不对,虽然可以解决当前的问题,但是在可移植性和性能上都存在问题。所以我设计了这个头像生成方式,并且对后续H5 API提供兼容。
那么接下里来说具体实现:
TL;DR(太长不看)。
首先看这个框架的类图:
简单来说是有BerryBuilder
收集参数信息,此信息创建BerryLayoutParams
对象。BerryLayoutParams
为抽象类,其直接实现类包括一个GridLayoutParams
和CircleLayoutParams
风别用于生成九宫格布局和圆形布局,直观来说就是微信群组样式和QQ多人聊天样式。
废话不多说先看效果图:
1 | public class BerryAvatarGenerator{ |
以上为实际图像生成和绘制的核心逻辑代码,这里可以看出实际上真正控制图像绘制是BerryLayoutParams
实例。其实也正是因为最初的UI堆叠实现启发了我,Android上控件是根据布局中的LayoutParams
来控制。所以我想与其整体对图像进行控制,那为什么不在绘制前就计算出每个子元素的位置,之后我们思考的仅仅就像布局的onLayout()
那样根据参数绘制了。而具体绘制就像android控件一样自己控制。
1 | public abstract class BerryLayoutParams { |
BerryLayoutParams
的作用实际上仅仅是为了存储每个子元素的左上角坐标和资源宽高。后来我觉得可能在后续对子元素进行定制处理,所以就干脆把绘制也委托给LayoutParams
中实现。
1 |
|
如上述代码片段BerryLayoutParams
子类其实上都是同样的实现,仅仅是将某元素按照布局样式绘制到画布上。这里九宫格布局算法没什么好讲的,跟着直觉走就是了。不过需要注意的是这里关于图片的处理都要使用相对大小做处理,也就是说所有数值都是根据BerryBuilder
设置的图像边界的大小、子元素个数推算出来,也只有这样才能保持代码的通用性。由于这类头像生成无论是方形还是圆形,实际上最外层都是正方形,所以我们值需要两个输入,即maxSize
和sourceCount
。
绘制圆形
由于圆形绘制有非常明显的规律性,所以这里简单介绍一下由特殊模型推出一般模型方法。直接上图
如上图为比较极端的情况,但是这可以非常明确的看出本质问题。
- 绿色圆形:首先绿色圆形为整个正方形画布的内切圆。其内部为所有所有子元素的可显示范围,即子元素都在这个绿色圆形中。
- 紫色圆形:紫色圆形为以绿色圆形半径1/2的同心圆。
- 黑色圆形:黑色圆形为最外层切于大圆最内侧经过大圆圆心。可以看出其直径为大圆半径,圆心位于紫色圆的周长划过的弧上。同时黑色圆与紫色圆大小相同。
经过上述说明可以发现图中的黑色圆形,实际上就是圆形头像的绘制位置。而确定一个圆形我们只需要知道其圆心坐标和半径即可,而这些都可以从紫色圆形计算获得。接下来直接给出上述UI控件源码。
1 | public class CircleView extends View { |
如上述代码,其中省略了必要的构造方法和一些getter/setter
方法,这里只关注onDraw
方法即可。可以看出紫色圆形弧上的坐标为
- X:Math.cos(angle Math.PI / 180) radius + diameter
- Y:Math.sin(angle Math.PI / 180) radius + diameter
这里值得注意的是角度与弧度的问题
这里演示的是非常特殊的情况,即四个圆都在坐标轴上,显然使用中肯定有更多灵活的需求,所以最简单的方式就是加一个偏差值常量,让计算的起始角度偏移。如上述代码中int angle = degree * index
部分,如果在此结果中增加360 / 4
那么最终圆形会向右偏转90度。同时你会发现实际绘制出来图像充满画布,非常不好看,那么实际上只需要控制黑色圆的半径即可。但是显示中的效果要原本比这些复杂的多,最初的预览图就是仅仅调半径后的效果,可以非常明显的看出绘制过程。但是并不实用,于是要继续修改,如下图。
上图为最终效果,随着头像元素的个数变化实际上黑色圆心也是在发生变化。所以实际使用中算法思想反而更接近九宫格图像,要根据子元素数量计算出紫色圆形的半径,计算出弧度坐标后再计算出黑色圆形比较合适的半径。这里可以发现当元素足够少时黑色圆形是覆盖了大圆圆心的。
最后返回这个绘制好的Bitmap
,此Bitmap可以直接显示在ImageView
上。但是需要注意的是这是一个阻塞式操作,如果直接绘制在UI上还需要进行线程切换。个人比较推荐的是再设计一个类似于AvatarImageManager
的工具,此工具将返回的Bitmap
写入磁盘并向外发送这个磁盘地址,这样就有更好的适用性。
最后
就像我最开始说的那样从开始设计之初,这个思路非常直接明了,但是具体实现上有非常多的问题,甚至很多参数需要多次调整。因为计算获得的数据可能与人的直观感受不同,这些甚至随着产品的生命周期在不停变化。而这些都是之后慢慢优化和调整的工作了。
画布剪切
最后简单说一些图片处理的问题,图片的形状处理可以在两个地方。分别是Bitmap处理和画布处理,而这两个处理方式都有自己的使用场景。
Bitmap操作
对Bitmap的操作多用于后台线程,首先是Bitmap.createBitmap(...)
等一系列常规方式。但是对于稍微复杂的样式就需要使用画布处理,如绘制一个圆形Bitmap。
1 | Bitmap circleBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); |
这里可以看出使用PorterDuffXfermode
裁剪画布,这相比直接对Bitmap进行操作已经直观很多了。但是更加复杂的图片处理可能需要另外的算法对矩阵进行操作了。
画布操作
上面对于图像圆形的处理虽然很直观,但是总觉得并不简洁,所以我们可以从另一方面考虑。既然最终都是在画布上操作,那么能不能在UI绘制的时候做处理,直接选择将画布裁剪成圆形不就好了吗?我想说这当然是可以的。
1 | public class AvatarImageView extends ImageView { |
如上述代码片段为一个自定义的图像控件,可以看出当设置显示类型为BerryBuilder.STYLE_CIRCLE
的时候将画布裁剪成圆形,这样别的不说,其带来的灵活性不言而喻。可以全局控制样式也可以针对控制某些特殊的位置展示。
上图头像展示中第三行的圆形灰色背景实际是被图片控件裁剪过的,和第二行最后一个正方形做一个比较好的对比。
这种做法最为重要的是没有多余的Bitmap创建与销毁,没有各种画笔图形设置,我们需要的只是在画布上画一个圆,然后裁剪圆之外的部分。不过其实这两种方法核心部分是可以通用的,但是就因为在控件中我们会更加注意代码的简洁和效率。不过从设计上来说我更倾向于第二种思想,因为其有更强的适用性和更直观的控制性。本质上这两种方法是不冲突的。
最后
移动端设计虽然也是一种客户端设计,但是由于和用户交互前所未有的紧密型,想要在用户使用中带来平滑自然的效果,实际上是非常困难的。这不仅仅是控件定制和动画设计的问题,更多的底层数据与功能的驱动。甚至也和服务端有密切关联,服务端一个不好的API设计可能大量增加移动端数据和逻辑的复杂度。而这些最终都反映在用户的实际使用上,并且是悄无声息的。
系统任何的一部分都是系统不可分割的一部分,都需要高度重视并统一规划与设计。