本文的合集已经编著成书,高级Android开发强化实战,欢迎各位读友的建议和指导。在京东即可购买:https://item.jd.com/12385680.html
Emoji (絵文字 或 えもじ; 日语发音: [emodʑi]) 是日本无线通讯中所使用的视觉情感符号, 绘代表图形, 文字是图形本身的隐喻. 用于输入者表达情感信息, 如笑脸就代表开心😊, 蛋糕就代表食物🍰等. 形象生动, 在文字中出现图片, 更容易实现情感的表述.
Emoji起初只能在日本使用, 如今相当一部分的Emoji字符集已经被收入Unicode编码, 使其能被广泛应用. Android系统对于Emoji的原生支持从4.4版本开始. 对于文字输入型应用而言, 自定义的Emoji表情会大幅提升用户体验, 增强用户对于应用的辨识度, 也使输入更加有趣. 原生的Emoji表情由于需要适配多款机型, 节省存储空间, 所以设计得较为粗糙. 优秀美工重绘的Emoji表情, 一般都会更加符合用户的视觉习惯, 这就是QQ和微信大量重绘Emoji的原因.
本文介绍Emoji表情的实现方式, 具体效果参考春雨医生的在线问诊页面.
下载Emoji列表
Emoji表情数据的存储方式有两种, 第一种在本地, 随着应用一起分发; 第二种在远程, 访问服务器获取. 显然第二种更为合理, 易于修改和替换, 方便重绘Emoji表情的后续扩容. 从远程服务器中获取Emoji数据时, 注意需要使用有序列表, 因为根据用户的使用习惯不同, 有些常用表情在先, 有些不常用在后. 考虑列表的有序性, 选择ArrayList-Pair数据结构传输, 而非Map, 因为列表是有序的, 而Map是无序的, 也可以选择LinkedHashMap.
本例Emoji数据集的数据结构是ArrayList<Pair<String, String>>
, 其中Pair的Key
是Emoji的Unicode字符, Value
是Emoji表情的下载地址.
// 下载Emoji表情并缓存
ArrayList<Pair<String, String>> pairs = remoteData.getChunyuEmoji();
if (pairs != null) {
saveEmoji(context, pairs);
}
在获取Emoji表情集合的全部表情下载地址后, 将这些表情缓存至本地, 统一更新, 减少访问远程服务器的次数, 节省流量和电量. 表情集合存储在BitmapLruCache
类中, 即LRU缓存类, 其缓存模块使用内存(Memory)与本地硬盘(Disk)的二级缓存. 注意下载过程需要在非UI线程中进行, 即EmojiDownloadAsyncTasks
.
/**
* 下载并缓存Emoji标签
*
* @param context 上下文
* @param pairs 表情对[Emoji符号, Emoji下载地址]
*/
private void saveEmoji(@NonNull Context context, @NonNull ArrayList<Pair<String, String>> pairs) {
// 当未提供数据时, 不刷新Emoji的数据
if (pairs.size() == 0) {
return;
}
ArrayList<String> urls = new ArrayList<>();
for (Pair<String, String> pair : pairs) {
urls.add(pair.second);
}
new EmojiDownloadAsyncTasks(context, urls).execute();
}
// Emoji表情的异步下载链接, 存储至缓存
public static class EmojiDownloadAsyncTasks extends AsyncTask<Void, Void, Void> {
private final Context mContext;
private final ArrayList<String> mUrls;
public EmojiDownloadAsyncTasks(
final @NonNull Context context,
final @NonNull ArrayList<String> urls) {
mContext = context.getApplicationContext();
mUrls = urls;
}
@Override
protected @Nullable Void doInBackground(Void... params) {
BitmapLruCache cache = BitmapLruCache.getInstance(mContext);
for (int i = 0; i < mUrls.size(); ++i) {
try {
cache.addBitmapToCache(mUrls.get(i));
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}
缓存Emoji数据
为了快速地访问Emoji表情, 为其添加图片缓存必不可少. 本例的缓存类是BitmapLruCache
, 其内部使用常见的二级缓存, 即内存缓存和硬盘缓存.
注意: 为了加快开发和减少错误, 尽量选择复用已有的轮子. 内存缓存使用Android系统自带的
LruCache
; 外存缓存使用DiskLruCache
(Jake Wharton).
private static final String EMOJI_FOLDER = "bitmap"; // Bitmap的缓存文件夹
private static final int CACHE_VERSION = 1; // 缓存文件版本
private static final int CACHE_SIZE = 1024 * 1024 * 20; // 缓存文件大小
private LruCache<String, Bitmap> mMemoryCache; // 内存缓存
private DiskLruCache mDiskCache; // DiskLruCache, 硬盘缓存
private final Context mContext; // 上下文
private static BitmapLruCache sInstance; // 单例
private BitmapLruCache(@NonNull final Context context) {
mContext = context.getApplicationContext();
initMemoryCache(); // 初始化内存缓存
initDiskCache(mContext); // 初始化磁盘缓存
}
/**
* 初始化内存缓存
*/
private void initMemoryCache() {
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
final int cacheSize = maxMemory / 4;
mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override protected int sizeOf(String key, Bitmap value) {
return value.getRowBytes() * value.getHeight() / 1024;
}
};
}
/**
* 初始化外存缓存
*
* @param context 上下文
*/
private void initDiskCache(@NonNull final Context context) {
// 获取缓存文件
File diskCacheDir = getDiskCacheDir(context);
// 如果文件不存在, 则创建
if (!diskCacheDir.exists()) {
if (!diskCacheDir.mkdirs()) {
Log.e("BitmapLruCache", "ERROR: 创建缓存失败");
}
}
try {
// 创建缓存地址
mDiskCache = DiskLruCache.open(diskCacheDir, CACHE_VERSION, 1, CACHE_SIZE);
} catch (IOException e) {
e.printStackTrace();
}
}
类中的addBitmapToCache
方法, 将表情下载的url
作为缓存映射Map
的唯一Key
. 下载后的Bitmap
, 会优先写入外存缓存, 再同步写入内存缓存.
/**
* 将Bitmap写入缓存
*
* @param url Bitmap的网络Url(唯一标识)
* @throws IOException
*/
public void addBitmapToCache(final @NonNull String url) throws IOException {
if (mDiskCache == null || TextUtils.isEmpty(url)) {
return;
}
String key = hashKeyFormUrl(url); // Url的Key
DiskLruCache.Editor editor = mDiskCache.edit(key); // 得到Editor对象
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(0);
// 根据输出流的返回值决定是否提交至缓存
if (downloadUrlToStream(url, outputStream)) {
// 提交写入操作
editor.commit();
} else {
// 撤销写入操作
editor.abort();
}
mDiskCache.flush(); // 更新缓存
}
getBitmapFromCache(url); // 加载内存缓存
}
类中的getBitmapFromCache
方法, 根据唯一标识下载url
, 获取Bitmap
. 优先从内存中获取, 当内存缓存不存在时, 从外存读取, 再同步写入内存; 当内存缓存存在时, 直接返回.
注意: Emoji表情一般都使用较小尺寸, 当图片加载入内存时, 防止图片过大, 优先进行压缩, 避免占用内存过多, 产生OOM. 尺寸大小支持外部配置.
/**
* 从缓存中取出Bitmap
*
* @param url 网络Url的地址, 图片的唯一标识
* @return url匹配的Bitmap
* @throws IOException
*/
public Bitmap getBitmapFromCache(final @NonNull String url) throws IOException {
//如果缓存中为空 直接返回为空
if (mDiskCache == null || mMemoryCache == null || TextUtils.isEmpty(url)) {
return null;
}
// 通过key值在缓存中找到对应的Bitmap
String key = hashKeyFormUrl(url);
Bitmap bitmap = mMemoryCache.get(key);
if (bitmap == null) {
// 通过key得到Snapshot对象
DiskLruCache.Snapshot snapShot = mDiskCache.get(key);
if (snapShot != null) {
// 得到文件输入流
InputStream ins = snapShot.getInputStream(0);
bitmap = BitmapFactory.decodeStream(ins);
}
if (bitmap != null) {
// 设置图片大小, 防止内存缓存溢出, 节省内存
int size = AppUtils.spToPx(mContext, mBitmapSize); // 默认18
bitmap = Bitmap.createScaledBitmap(bitmap, size, size, true);
mMemoryCache.put(key, bitmap);
}
}
return bitmap;
}
管理Emoji数据
本例使用EmojiFileManager
类作为Emoji表情集合的管理器, 同时作为接口, 向外部提供数据和方法. 原始的有序列表转换为无需映射HashMap
, 便于快速查找表情; 转换为分页列表, 使用List<List<EmojiIcon>>
匹配ViewPager
的表情分页显示.
/**
* 初始化Emoji的数据
*/
public void initEmojiData() {
DailyRequestData data = DailyRequestManager.getInstance().getLocalData();
if (data != null) {
// Emoji的有序列表
ArrayList<Pair<String, String>> emojiPairList = data.getChunyuEmoji();
if (!Utils.isListEmpty(emojiPairList)) {
parseData(emojiPairList); // 结构化Emoji数据列表
}
}
}
/**
* 解析数据, 提前分页设置, 每页的表情数PAGE_SIZE.
*
* @param pairs Emoji的Map
*/
private void parseData(@NonNull final ArrayList<Pair<String, String>> pairs) {
// 当解析数据为空时, 直接返回
if (Utils.isListEmpty(pairs)) {
return;
}
// 转换成为HashMap, 快速查找
mEmojiMap = convertPairList2Map(pairs);
// 转换为PageList, 用于ViewPager
mEmojiPageLists = convertPairToPageList(pairs, PAGE_SIZE);
}
类中convertPairList2Map
的方法, 将ArrayList-Pair数据结构转换为HashMap
, 加快Emoji表情的查找速度.; 类中convertPairToPageList
的方法, 将原始结构ArrayList-Pair, 组合成EmojiIcon
的数组, 再根据每页显示个数, 重构成二维数组, 用于ViewPager
的表情分页显示.
/**
* 将有序的PairList转换为无序的Map
*
* @param pairs 列表
* @return 无序Map
*/
private static Map<String, String> convertPairList2Map(
final @NonNull ArrayList<Pair<String, String>> pairs) {
Map<String, String> map = new HashMap<>(); // 快速查找
for (int i = 0; i < pairs.size(); ++i) {
map.put(pairs.get(i).first, pairs.get(i).second);
}
return map;
}
/**
* 将有序的PairList转换为按页的List数组
*
* @param pairs 列表
* @param page_size 每页数量
* @return 按页的List数组
*/
private List<List<EmojiIcon>> convertPairToPageList(
final @NonNull ArrayList<Pair<String, String>> pairs,
final int page_size) {
List<List<EmojiIcon>> emojiPageLists = new ArrayList<>();
// 保存于内存中的表情集合
ArrayList<EmojiIcon> emojiIcons = new ArrayList<>();
EmojiIcon emojiEntry;
// 遍历列表, 放入列表
for (Pair<String, String> entry : pairs) {
emojiEntry = new EmojiIcon();
emojiEntry.setUnicode(entry.first);
emojiEntry.setUrl(entry.second);
emojiIcons.add(emojiEntry);
}
// 每一个页数
int pageCount = (int) Math.ceil(emojiIcons.size() / page_size + 0.1);
for (int i = 0; i < pageCount; i++) {
emojiPageLists.add(getListData(emojiIcons, i)); // 获取每页数据
}
return emojiPageLists;
}
替换Emoji表情
在字符串中, 替换Emoji表情的方式主要有两种: 第一种是在已有字符串中查找已经存在的Emoji编码, 替换为相应的表情; 第二种是创建单个Emoji表情的字符串.
类中的getExpressionString
方法, 设置查找模式, 调用dealExpression
替换相应Emoji表情, 并返回支持文字和图片的组合的SpannableString
类型.
注意: 在
Pattern
中设置Pattern.UNICODE_CASE
参数, 使其仅检查Unicode字符串, 缩小范围, 可以显著提升匹配速度, 否则在字符串较长时, 匹配速度较慢.
/**
* 获得SpannableString对象, 通过传入的字符串, 进行正则判断
*
* @param context 上下文
* @param str 输入字符串
* @return 组合字符串
*/
public SpannableString getExpressionString(
@NonNull final Context context,
@NonNull final CharSequence str) {
SpannableString spannableString = new SpannableString(str);
// 正则表达式比配字符串里是否含有表情, 通过传入的正则表达式来生成Pattern
// 注意Pattern的模式, 大小写不敏感, Unicode, 加快检索速度
Pattern emojiPattern = Pattern.compile(EMOJI_REGEX,
Pattern.UNICODE_CASE | Pattern.CASE_INSENSITIVE);
try {
dealExpression(context, spannableString, emojiPattern, 0);
} catch (Exception e) {
Log.e(LOG_TAG, e.getMessage());
}
return spannableString;
}
类中dealExpression
方法查找匹配字符串, 调用addBitmap2Spannable
替换图片, 并递归解析剩下的字符串, 直至全部替换完成. 具体步骤:
- 将所需替换的字符串与Emoji的Unicode标准编码匹配, 组成
Matcher
. - 如果
Matcher
匹配成功, 则获取相应的字符串key
. - 如果Emoji字典中存在这个
key
, 则获取Emoji的对应url
. - 如果
url
存在, 则调用addBitmap2Spannable
替换字符串为Emoji表情. - 继续递归调用, 解析剩下的字符串.
/**
* 对SpannableString进行正则判断,如果符合要求,则以表情图片代替
*
* @param context 上下文
* @param spannable 组合字符串
* @param patten 模式
* @param start 递归起始位置
*/
private void dealExpression(
@NonNull final Context context, SpannableString spannable,
Pattern patten, final int start) {
if (start < 0) {
return;
}
// 将字符串与模式创建匹配
Matcher matcher = patten.matcher(spannable);
// 匹配成功
while (matcher.find()) {
String key = matcher.group().toLowerCase(); // 默认小写
// 返回第一个字符的索引的文本匹配整个正则表达式, 如果是true则继续递归
if (matcher.start() < start) {
continue;
}
// 根据Key获取URL
String url = mEmojiMap.get(key);
// 通过上面匹配得到的字符串来生成图片资源id
if (!TextUtils.isEmpty(url)) {
// 计算该图片名字的长度,也就是要替换的字符串的长度
int end = matcher.start() + key.length();
spannable = addBitmap2Spannable(context, url, spannable, matcher.start(), end);
if (end < spannable.length()) {
// 如果整个字符串还未验证完,则继续
dealExpression(context, spannable, patten, end);
}
break;
}
}
}
类中的addBitmap2Spannable
方法, 根据Emoji的url
, 从图片缓存BitmapLruCache
中获取相应的表情(Bitmap
), 创建居中对齐的VerticalImageSpan
, 与文字组合成SpannableString
.
/**
* 添加图片至Spannable
*
* @param context 上下文
* @param url 图片网络连接
* @param spannable 文字
* @param start 起始修改
* @param end 终止修改
* @return 添加图片后的文字
*/
private SpannableString addBitmap2Spannable(
Context context, String url,
SpannableString spannable, int start, int end) {
// 当bitmap为空时, 无法替换内容
Bitmap bitmap = null;
try {
bitmap = BitmapLruCache.getInstance(context).getBitmapFromCache(url);
} catch (IOException e) {
e.printStackTrace();
}
VerticalImageSpan imageSpan = new VerticalImageSpan(context, bitmap);
spannable.setSpan(imageSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
}
默认的ImageSpan
参数不包含居中显示, 重写getSize
和draw
方法, 使ImageSpan
居中对齐于文字, 注意位置数据的设置.
/**
* 竖直居中的ImageSpan
*
* Created by wangchenlong on 17/2/7.
*/
public class VerticalImageSpan extends ImageSpan {
private WeakReference<Drawable> mDrawableRef;
private static boolean DEBUG = false;
private Context mContext;
public VerticalImageSpan(Context context, Bitmap bitmap) {
super(context, bitmap);
mContext = context;
}
@Override
public int getSize(Paint paint, CharSequence text,
int start, int end,
Paint.FontMetricsInt fm) {
Drawable d = getCachedDrawable();
Rect rect = d.getBounds();
if (fm != null) {
Paint.FontMetricsInt pfm = paint.getFontMetricsInt();
// keep it the same as paint's fm
fm.ascent = pfm.ascent;
fm.descent = pfm.descent;
fm.top = pfm.top;
fm.bottom = pfm.bottom;
}
return rect.right;
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text,
int start, int end, float x,
int top, int y, int bottom, @NonNull Paint paint) {
Drawable b = getCachedDrawable();
canvas.save();
int drawableHeight = b.getIntrinsicHeight();
int fontAscent = paint.getFontMetricsInt().ascent;
int fontDescent = paint.getFontMetricsInt().descent;
int offset = (bottom - top) - drawableHeight - (AppUtils.spToPx(mContext, 1) + 1);
int transY = (bottom - offset) - b.getBounds().bottom + // align bottom to bottom
(drawableHeight - fontDescent + fontAscent) / 2; // align center to center
canvas.translate(x, transY);
b.draw(canvas);
canvas.restore();
}
// Redefined locally because it is a private member from DynamicDrawableSpan
private Drawable getCachedDrawable() {
WeakReference<Drawable> wr = mDrawableRef;
Drawable d = null;
if (wr != null)
d = wr.get();
if (d == null) {
d = getDrawable();
mDrawableRef = new WeakReference<>(d);
}
return d;
}
}
类中的addIcon
方法, 创建单个Emoji表情的字符串. 通过addBitmap2Spannable
方法, 将Emoji编码字符串替换为表情.
/**
* 添加表情, 根据URL至BitmapDiskLruCache中匹配
*
* @param context 上下文
* @param url 图片的网络URL
* @param string 字符串
* @return
*/
public SpannableString addIcon(Context context, String url, String string) {
SpannableString spannable = new SpannableString(string);
return addBitmap2Spannable(context, url, spannable, 0, string.length());
}
在需要替换Emoji表情的位置, 调用EmojiFileManager
的getExpressionString
方法, 将字符串中的Emoji编码替换为Emoji表情; 在需要添加Emoji表情的位置, 调用其addIcon
方法获取单个Emoji表情, 与已存在的字符串, 拼接成最终字符串.
效果如下:
为文字输入型应用添加Emoji表情吧, 让输入获得更多乐趣.
That's all! Enjoy it!