Emoji's World, 一起實(shí)現(xiàn)Emoji??輸入吧!

歡迎Follow我的GitHub, 關(guān)注我的簡書, 博客目錄.

Emoji

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

Android

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替換圖片, 并遞歸解析剩下的字符串, 直至全部替換完成. 具體步驟:

  1. 將所需替換的字符串與Emoji的Unicode標(biāo)準(zhǔn)編碼匹配, 組成Matcher.
  2. 如果Matcher匹配成功, 則獲取相應(yīng)的字符串key.
  3. 如果Emoji字典中存在這個(gè)key, 則獲取Emoji的對應(yīng)url.
  4. 如果url存在, 則調(diào)用addBitmap2Spannable替換字符串為Emoji表情.
  5. 繼續(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ù)不包含居中顯示, 重寫getSizedraw方法, 使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)用EmojiFileManagergetExpressionString方法, 將字符串中的Emoji編碼替換為Emoji表情; 在需要添加Emoji表情的位置, 調(diào)用其addIcon方法獲取單個(gè)Emoji表情, 與已存在的字符串, 拼接成最終字符串.

效果如下:

Emoji

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

That's all! Enjoy it!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 1. Java基礎(chǔ)部分 基礎(chǔ)部分的順序:基本語法,類相關(guān)的語法,內(nèi)部類的語法,繼承相關(guān)的語法,異常的語法,線程的語...
    子非魚_t_閱讀 34,839評論 18 399
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 179,323評論 25 708
  • (默寫截圖) 文︳大之 1 前幾晚,連續(xù)奮戰(zhàn),為你把小學(xué)英語單詞給總結(jié)了出來。剛上七年級的學(xué)生,竟要回頭再補(bǔ)小學(xué)時(shí)...
    大之閱讀 759評論 2 2
  • 目的 在Tensorflow的教程里面,使用梯度下降算法訓(xùn)練神經(jīng)網(wǎng)絡(luò)時(shí),都會(huì)提到一個(gè)使模型更加健壯的策略,即滑動(dòng)平...
    ledao閱讀 5,071評論 4 49
  • 燈,是居家生活中的必需品。 隨著時(shí)代的進(jìn)步與科學(xué)的發(fā)展,人們對燈越來越講究。不僅僅具備照明,還具備裝飾美觀,渲染環(huán)...
    文采樂閱讀 294評論 5 6

友情鏈接更多精彩內(nèi)容