
本文的合集已經(jīng)編著成書,高級Android開發(fā)強(qiáng)化實(shí)戰(zhàn),歡迎各位讀友的建議和指導(dǎo)。在京東即可購買:https://item.jd.com/12385680.html

Emoji (絵文字 或 えもじ; 日語發(fā)音: [emod?i]) 是日本無線通訊中所使用的視覺情感符號, 繪代表圖形, 文字是圖形本身的隱喻. 用于輸入者表達(dá)情感信息, 如笑臉就代表開心??, 蛋糕就代表食物??等. 形象生動(dòng), 在文字中出現(xiàn)圖片, 更容易實(shí)現(xiàn)情感的表述.
Emoji起初只能在日本使用, 如今相當(dāng)一部分的Emoji字符集已經(jīng)被收入U(xiǎn)nicode編碼, 使其能被廣泛應(yīng)用. Android系統(tǒng)對于Emoji的原生支持從4.4版本開始. 對于文字輸入型應(yīng)用而言, 自定義的Emoji表情會(huì)大幅提升用戶體驗(yàn), 增強(qiáng)用戶對于應(yīng)用的辨識度, 也使輸入更加有趣. 原生的Emoji表情由于需要適配多款機(jī)型, 節(jié)省存儲空間, 所以設(shè)計(jì)得較為粗糙. 優(yōu)秀美工重繪的Emoji表情, 一般都會(huì)更加符合用戶的視覺習(xí)慣, 這就是QQ和微信大量重繪Emoji的原因.
本文介紹Emoji表情的實(shí)現(xiàn)方式, 具體效果參考春雨醫(yī)生的在線問診頁面.
下載Emoji列表
Emoji表情數(shù)據(jù)的存儲方式有兩種, 第一種在本地, 隨著應(yīng)用一起分發(fā); 第二種在遠(yuǎn)程, 訪問服務(wù)器獲取. 顯然第二種更為合理, 易于修改和替換, 方便重繪Emoji表情的后續(xù)擴(kuò)容. 從遠(yuǎn)程服務(wù)器中獲取Emoji數(shù)據(jù)時(shí), 注意需要使用有序列表, 因?yàn)楦鶕?jù)用戶的使用習(xí)慣不同, 有些常用表情在先, 有些不常用在后. 考慮列表的有序性, 選擇ArrayList-Pair數(shù)據(jù)結(jié)構(gòu)傳輸, 而非Map, 因?yàn)榱斜硎怯行虻? 而Map是無序的, 也可以選擇LinkedHashMap.
本例Emoji數(shù)據(jù)集的數(shù)據(jù)結(jié)構(gòu)是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表情集合的全部表情下載地址后, 將這些表情緩存至本地, 統(tǒng)一更新, 減少訪問遠(yuǎn)程服務(wù)器的次數(shù), 節(jié)省流量和電量. 表情集合存儲在BitmapLruCache類中, 即LRU緩存類, 其緩存模塊使用內(nèi)存(Memory)與本地硬盤(Disk)的二級緩存. 注意下載過程需要在非UI線程中進(jìn)行, 即EmojiDownloadAsyncTasks.
/**
* 下載并緩存Emoji標(biāo)簽
*
* @param context 上下文
* @param pairs 表情對[Emoji符號, Emoji下載地址]
*/
private void saveEmoji(@NonNull Context context, @NonNull ArrayList<Pair<String, String>> pairs) {
// 當(dāng)未提供數(shù)據(jù)時(shí), 不刷新Emoji的數(shù)據(jù)
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數(shù)據(jù)
為了快速地訪問Emoji表情, 為其添加圖片緩存必不可少. 本例的緩存類是BitmapLruCache, 其內(nèi)部使用常見的二級緩存, 即內(nèi)存緩存和硬盤緩存.
注意: 為了加快開發(fā)和減少錯(cuò)誤, 盡量選擇復(fù)用已有的輪子. 內(nèi)存緩存使用Android系統(tǒng)自帶的
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; // 內(nèi)存緩存
private DiskLruCache mDiskCache; // DiskLruCache, 硬盤緩存
private final Context mContext; // 上下文
private static BitmapLruCache sInstance; // 單例
private BitmapLruCache(@NonNull final Context context) {
mContext = context.getApplicationContext();
initMemoryCache(); // 初始化內(nèi)存緩存
initDiskCache(mContext); // 初始化磁盤緩存
}
/**
* 初始化內(nèi)存緩存
*/
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);
// 如果文件不存在, 則創(chuàng)建
if (!diskCacheDir.exists()) {
if (!diskCacheDir.mkdirs()) {
Log.e("BitmapLruCache", "ERROR: 創(chuàng)建緩存失敗");
}
}
try {
// 創(chuàng)建緩存地址
mDiskCache = DiskLruCache.open(diskCacheDir, CACHE_VERSION, 1, CACHE_SIZE);
} catch (IOException e) {
e.printStackTrace();
}
}
類中的addBitmapToCache方法, 將表情下載的url作為緩存映射Map的唯一Key. 下載后的Bitmap, 會(huì)優(yōu)先寫入外存緩存, 再同步寫入內(nèi)存緩存.
/**
* 將Bitmap寫入緩存
*
* @param url Bitmap的網(wǎng)絡(luò)Url(唯一標(biāo)識)
* @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);
// 根據(jù)輸出流的返回值決定是否提交至緩存
if (downloadUrlToStream(url, outputStream)) {
// 提交寫入操作
editor.commit();
} else {
// 撤銷寫入操作
editor.abort();
}
mDiskCache.flush(); // 更新緩存
}
getBitmapFromCache(url); // 加載內(nèi)存緩存
}
類中的getBitmapFromCache方法, 根據(jù)唯一標(biāo)識下載url, 獲取Bitmap. 優(yōu)先從內(nèi)存中獲取, 當(dāng)內(nèi)存緩存不存在時(shí), 從外存讀取, 再同步寫入內(nèi)存; 當(dāng)內(nèi)存緩存存在時(shí), 直接返回.
注意: Emoji表情一般都使用較小尺寸, 當(dāng)圖片加載入內(nèi)存時(shí), 防止圖片過大, 優(yōu)先進(jìn)行壓縮, 避免占用內(nèi)存過多, 產(chǎn)生OOM. 尺寸大小支持外部配置.
/**
* 從緩存中取出Bitmap
*
* @param url 網(wǎng)絡(luò)Url的地址, 圖片的唯一標(biāo)識
* @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值在緩存中找到對應(yīng)的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) {
// 設(shè)置圖片大小, 防止內(nèi)存緩存溢出, 節(jié)省內(nèi)存
int size = AppUtils.spToPx(mContext, mBitmapSize); // 默認(rèn)18
bitmap = Bitmap.createScaledBitmap(bitmap, size, size, true);
mMemoryCache.put(key, bitmap);
}
}
return bitmap;
}
管理Emoji數(shù)據(jù)
本例使用EmojiFileManager類作為Emoji表情集合的管理器, 同時(shí)作為接口, 向外部提供數(shù)據(jù)和方法. 原始的有序列表轉(zhuǎn)換為無需映射HashMap, 便于快速查找表情; 轉(zhuǎn)換為分頁列表, 使用List<List<EmojiIcon>>匹配ViewPager的表情分頁顯示.
/**
* 初始化Emoji的數(shù)據(jù)
*/
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); // 結(jié)構(gòu)化Emoji數(shù)據(jù)列表
}
}
}
/**
* 解析數(shù)據(jù), 提前分頁設(shè)置, 每頁的表情數(shù)PAGE_SIZE.
*
* @param pairs Emoji的Map
*/
private void parseData(@NonNull final ArrayList<Pair<String, String>> pairs) {
// 當(dāng)解析數(shù)據(jù)為空時(shí), 直接返回
if (Utils.isListEmpty(pairs)) {
return;
}
// 轉(zhuǎn)換成為HashMap, 快速查找
mEmojiMap = convertPairList2Map(pairs);
// 轉(zhuǎn)換為PageList, 用于ViewPager
mEmojiPageLists = convertPairToPageList(pairs, PAGE_SIZE);
}
類中convertPairList2Map的方法, 將ArrayList-Pair數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換為HashMap, 加快Emoji表情的查找速度.; 類中convertPairToPageList的方法, 將原始結(jié)構(gòu)ArrayList-Pair, 組合成EmojiIcon的數(shù)組, 再根據(jù)每頁顯示個(gè)數(shù), 重構(gòu)成二維數(shù)組, 用于ViewPager的表情分頁顯示.
/**
* 將有序的PairList轉(zhuǎn)換為無序的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轉(zhuǎn)換為按頁的List數(shù)組
*
* @param pairs 列表
* @param page_size 每頁數(shù)量
* @return 按頁的List數(shù)組
*/
private List<List<EmojiIcon>> convertPairToPageList(
final @NonNull ArrayList<Pair<String, String>> pairs,
final int page_size) {
List<List<EmojiIcon>> emojiPageLists = new ArrayList<>();
// 保存于內(nèi)存中的表情集合
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);
}
// 每一個(gè)頁數(shù)
int pageCount = (int) Math.ceil(emojiIcons.size() / page_size + 0.1);
for (int i = 0; i < pageCount; i++) {
emojiPageLists.add(getListData(emojiIcons, i)); // 獲取每頁數(shù)據(jù)
}
return emojiPageLists;
}
替換Emoji表情
在字符串中, 替換Emoji表情的方式主要有兩種: 第一種是在已有字符串中查找已經(jīng)存在的Emoji編碼, 替換為相應(yīng)的表情; 第二種是創(chuàng)建單個(gè)Emoji表情的字符串.
類中的getExpressionString方法, 設(shè)置查找模式, 調(diào)用dealExpression替換相應(yīng)Emoji表情, 并返回支持文字和圖片的組合的SpannableString類型.
注意: 在
Pattern中設(shè)置Pattern.UNICODE_CASE參數(shù), 使其僅檢查Unicode字符串, 縮小范圍, 可以顯著提升匹配速度, 否則在字符串較長時(shí), 匹配速度較慢.
/**
* 獲得SpannableString對象, 通過傳入的字符串, 進(jìn)行正則判斷
*
* @param context 上下文
* @param str 輸入字符串
* @return 組合字符串
*/
public SpannableString getExpressionString(
@NonNull final Context context,
@NonNull final CharSequence str) {
SpannableString spannableString = new SpannableString(str);
// 正則表達(dá)式比配字符串里是否含有表情, 通過傳入的正則表達(dá)式來生成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方法查找匹配字符串, 調(diào)用addBitmap2Spannable替換圖片, 并遞歸解析剩下的字符串, 直至全部替換完成. 具體步驟:
- 將所需替換的字符串與Emoji的Unicode標(biāo)準(zhǔn)編碼匹配, 組成
Matcher. - 如果
Matcher匹配成功, 則獲取相應(yīng)的字符串key. - 如果Emoji字典中存在這個(gè)
key, 則獲取Emoji的對應(yīng)url. - 如果
url存在, 則調(diào)用addBitmap2Spannable替換字符串為Emoji表情. - 繼續(xù)遞歸調(diào)用, 解析剩下的字符串.
/**
* 對SpannableString進(jìn)行正則判斷,如果符合要求,則以表情圖片代替
*
* @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;
}
// 將字符串與模式創(chuàng)建匹配
Matcher matcher = patten.matcher(spannable);
// 匹配成功
while (matcher.find()) {
String key = matcher.group().toLowerCase(); // 默認(rèn)小寫
// 返回第一個(gè)字符的索引的文本匹配整個(gè)正則表達(dá)式, 如果是true則繼續(xù)遞歸
if (matcher.start() < start) {
continue;
}
// 根據(jù)Key獲取URL
String url = mEmojiMap.get(key);
// 通過上面匹配得到的字符串來生成圖片資源id
if (!TextUtils.isEmpty(url)) {
// 計(jì)算該圖片名字的長度,也就是要替換的字符串的長度
int end = matcher.start() + key.length();
spannable = addBitmap2Spannable(context, url, spannable, matcher.start(), end);
if (end < spannable.length()) {
// 如果整個(gè)字符串還未驗(yàn)證完,則繼續(xù)
dealExpression(context, spannable, patten, end);
}
break;
}
}
}
類中的addBitmap2Spannable方法, 根據(jù)Emoji的url, 從圖片緩存BitmapLruCache中獲取相應(yīng)的表情(Bitmap), 創(chuàng)建居中對齊的VerticalImageSpan, 與文字組合成SpannableString.
/**
* 添加圖片至Spannable
*
* @param context 上下文
* @param url 圖片網(wǎng)絡(luò)連接
* @param spannable 文字
* @param start 起始修改
* @param end 終止修改
* @return 添加圖片后的文字
*/
private SpannableString addBitmap2Spannable(
Context context, String url,
SpannableString spannable, int start, int end) {
// 當(dāng)bitmap為空時(shí), 無法替換內(nèi)容
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;
}
默認(rèn)的ImageSpan參數(shù)不包含居中顯示, 重寫getSize和draw方法, 使ImageSpan居中對齊于文字, 注意位置數(shù)據(jù)的設(shè)置.
/**
* 豎直居中的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方法, 創(chuàng)建單個(gè)Emoji表情的字符串. 通過addBitmap2Spannable方法, 將Emoji編碼字符串替換為表情.
/**
* 添加表情, 根據(jù)URL至BitmapDiskLruCache中匹配
*
* @param context 上下文
* @param url 圖片的網(wǎng)絡(luò)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表情的位置, 調(diào)用EmojiFileManager的getExpressionString方法, 將字符串中的Emoji編碼替換為Emoji表情; 在需要添加Emoji表情的位置, 調(diào)用其addIcon方法獲取單個(gè)Emoji表情, 與已存在的字符串, 拼接成最終字符串.
效果如下:

為文字輸入型應(yīng)用添加Emoji表情吧, 讓輸入獲得更多樂趣.
That's all! Enjoy it!