Emoji's World, 一起实现Emoji😀输入吧!

欢迎Follow我的GitHub, 关注我的简书, 博客目录.

Emoji

本文的合集已经编著成书,高级Android开发强化实战,欢迎各位读友的建议和指导。在京东即可购买:https://item.jd.com/12385680.html

Android

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替换图片, 并递归解析剩下的字符串, 直至全部替换完成. 具体步骤:

  1. 将所需替换的字符串与Emoji的Unicode标准编码匹配, 组成Matcher.
  2. 如果Matcher匹配成功, 则获取相应的字符串key.
  3. 如果Emoji字典中存在这个key, 则获取Emoji的对应url.
  4. 如果url存在, 则调用addBitmap2Spannable替换字符串为Emoji表情.
  5. 继续递归调用, 解析剩下的字符串.
/**
 * 对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参数不包含居中显示, 重写getSizedraw方法, 使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表情的位置, 调用EmojiFileManagergetExpressionString方法, 将字符串中的Emoji编码替换为Emoji表情; 在需要添加Emoji表情的位置, 调用其addIcon方法获取单个Emoji表情, 与已存在的字符串, 拼接成最终字符串.

效果如下:

Emoji

为文字输入型应用添加Emoji表情吧, 让输入获得更多乐趣.

That's all! Enjoy it!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,560评论 18 399
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,391评论 25 707
  • (默写截图) 文︳大之 1 前几晚,连续奋战,为你把小学英语单词给总结了出来。刚上七年级的学生,竟要回头再补小学时...
    大之阅读 698评论 2 2
  • 目的 在Tensorflow的教程里面,使用梯度下降算法训练神经网络时,都会提到一个使模型更加健壮的策略,即滑动平...
    ledao阅读 4,936评论 4 50
  • 灯,是居家生活中的必需品。 随着时代的进步与科学的发展,人们对灯越来越讲究。不仅仅具备照明,还具备装饰美观,渲染环...
    文采乐阅读 255评论 5 6