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的Key
是Emoji的Unicode字元, Value
是Emoji表情的下載地址.
1 2 3 4 5 |
// 下載Emoji表情並快取 ArrayList<Pair<String, String>> pairs = remoteData.getChunyuEmoji(); if (pairs != null) { saveEmoji(context, pairs); } |
在獲取Emoji表情集合的全部表情下載地址後, 將這些表情快取至本地, 統一更新, 減少訪問遠端伺服器的次數, 節省流量和電量. 表情集合儲存在BitmapLruCache
類中, 即LRU快取類, 其快取模組使用記憶體(Memory)與本地硬碟(Disk)的二級快取. 注意下載過程需要在非UI執行緒中進行, 即EmojiDownloadAsyncTasks
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
/** * 下載並快取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).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
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
, 會優先寫入外存快取, 再同步寫入記憶體快取.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/** * 將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. 尺寸大小支援外部配置.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
/** * 從快取中取出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>
匹配ViewPager
的表情分頁顯示.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
/** * 初始化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
的表情分頁顯示.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
/** * 將有序的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字串, 縮小範圍, 可以顯著提升匹配速度, 否則在字串較長時, 匹配速度較慢.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
/** * 獲得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表情. - 繼續遞迴呼叫, 解析剩下的字串.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
/** * 對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
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
/** * 新增圖片至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
居中對齊於文字, 注意位置資料的設定.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
/** * 豎直居中的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編碼字串替換為表情.
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * 新增表情, 根據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!
請使用手機”掃一掃”x
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!