Emoji (絵文字 或 えもじ; 日语发音: [emodʑi]) 是日本无线通讯中所使用的视觉情感符号, 绘代表图形, 文字是图形本身的隐喻. 用于输入者表达情感信息, 如笑脸就代表开心, 蛋糕就代表食物等. 形象生动, 在文字中出现图片, 更容易实现情感的表述.
Emoji起初只能在日本使用, 如今相当一部分的Emoji字符集已经被收入Unicode编码, 使其能被广泛应用. Android系统对于Emoji的原生支持从4.4版本开始. 对于文字输入型应用而言, 自定义的Emoji表情会大幅提升用户体验, 增强用户对于应用的辨识度, 也使输入更加有趣. 原生的Emoji表情由于需要适配多款机型, 节省存储空间, 所以设计得较为粗糙. 优秀美工重绘的Emoji表情, 一般都会更加符合用户的视觉习惯, 这就是QQ和微信大量重绘Emoji的原因.
本文介绍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
为文字输入型应用添加Emoji表情吧, 让输入获得更多乐趣.
That's all! Enjoy it!
共同学习,写下你的评论
评论加载中...
作者其他优质文章