
简介压缩包内含一套可运行的 Android 工程演示如何在 EditText 中接入表情输入创建自定义 EmoticonEditText通过 GridView 展示表情点击后按光标位置插入 Unicode 字符同时提供正则表达式拆分与 ImageSpan 图片替换方案用于展示带表情的字符串。面向需要实现聊天、社交评论等功能的 Android 开发者适合在现有项目里快速移植或学习扩展。资源共 72 个文件其中 33 个 png 表情素材、11 个 xml 布局与配置、3 个关键 java 源码为学习主体另有 15 个 class、2 个 jar、1 个 apk 等编译产物压缩后仅 1.54MB结构还原度较高可直接导入 ADT/Eclipse 或抽取代码复用。已有 186 人学习/下载说明具备一定参考价值。通过阅读源码与运行示例可以掌握表情键盘的适配方式、选中文本的替换技巧、表情字符与图片的映射思路以及性能优化建议减少在输入法交互、光标控制、中文文本解析上的试错成本是一份小而完整的实战参考资料。 做IM模块的时候“android edittext 添加表情”这个需求看起来简单实际落地时坑不少。网上很多教程只讲了“往EditText里塞图片”这一步但真正放到项目里光标定位、删除逻辑、面板与软键盘切换、消息发送前后的表情解析存储每一个环节都会冒出问题。这篇文章直接把我最终采用的完整实现方案、核心代码和踩坑记录整理出来包含了Unicode Emoji和自定义图片表情两种处理方式适合正在做聊天输入框、评论框或者任何需要表情输入功能的Android开发同学参考。1. 项目概述与整体思路拆解1.1 表情功能的需求拆解不只是“插入字符”那么简单表面看EditText加表情就是在光标处插入一段文本但拆开看完整的需求链条是这样的表情形式系统原生Unicode Emoji和自定义图片表情类似微信的黄脸表情包两者显示方式完全不同底层处理逻辑也完全不一样。插入位置必须定位在光标的当前位置如果用户在输入框里选中了一段文字插入表情时还应该覆盖选中区域。删除语义按退格键时Unicode Emoji按字符删没问题但图片表情是一个整体必须整张删除不能一次删半个更不能每次都触发系统默认的字符删除逻辑。面板交互表情面板本质是一个独立View它和软键盘之间存在“你上我下”的互斥关系这块处理不好会出现面板和键盘把输入框顶来顶去的视觉跳动。序列化存储EditText里显示的是图片或特殊字符但发出去的消息、存进数据库的内容必须是可解析的字符串否则渲染端就不知道该显示表情图片还是纯文本。这几个点如果只在测试demo里跑通上线后用户一用就露馅。1.2 方案选型Unicode Emoji与图片表情怎么取舍先给结论如果项目对表情风格有统一要求比如品牌形象、运营活动要达到某种视觉效果必须走自定义图片表情方案如果只是想要系统表情直接用Unicode Emoji就够了成本最低。对比维度Unicode Emoji自定义图片表情接入成本低直接插入字符高需要面板、资源、解析体系显示一致性依赖系统版本安卓各厂商渲染差异明显完全统一存储格式原样保存天然可传输需要转成占位符字符串删除逻辑按字符删即可需要识别跨度Span整体删除适用场景普通评论、轻量表达即时通讯、带品牌定制的App实际项目里我见过不少团队只做其中一种。如果你做的是类似IM的深度聊天场景我建议两者都支持Unicode Emoji作为基础兜底自定义表情满足运营定制。本文后续的方案也按“两者都支持”来设计读者完全可以按需裁剪。2. 表情面板搭建与数据源设计2.1 表情资源组织静态目录还是云端下发自定义表情资源我建议按包名组织。assets目录下建emoji文件夹每个表情一个子目录里面放icon.png面板展示图和key.txt或统一配置文件描述表情标识例如[微笑]、[大哭]。如果是一个动态运营的场景表情包会迭代更新那就不能打包在assets里了应该走云端下发。表情包的云端结构一般是JSON配置加文件下载配置里至少包含{ emojiId: happy_2024, version: 3, list: [ { name: smile, code: [微笑], url: https://cdn.example.com/emoji/smile.png }, { name: cry, code: [大哭], url: https://cdn.example.com/emoji/cry.png } ] }不管用哪种方式数据层一定要抽象成统一的EmojiItem模型把“展示图片资源”和“逻辑标识代码”绑定。这样后面做插入、删除、解析都不需要关心表情到底是从本地读的还是网络下的。2.2 表情面板实现ViewPager加GridView的老牌组合面板常见实现是ViewPager GridView一页显示4行7列大概28个表情底部加一排指示点。这个方案虽然不是最前沿但胜在稳定而且表情面板对滑动性能要求不高用GridView完全够。关键代码结构大致如下class EmojiPanelView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) { private val viewPager: ViewPager private val pageIndicator: LinearLayout override fun onFinishInflate() { super.onFinishInflate() // 初始化ViewPager和指示器 } fun bindEmojiData(emojiList: ListEmojiItem) { val pageSize 27 // 每页27个表情最后一个位置放删除键 val pageCount ceil(emojiList.size / pageSize.toDouble()).toInt() val pages mutableListOfEmojiItem() repeat(pageCount) { page - val start page * pageSize val end min(start pageSize, emojiList.size) pages.addAll(emojiList.subList(start, end)) } val adapter EmojiGridAdapter(context, pages) viewPager.adapter adapter } }GridView每页的点击回调交给外部接口处理外面在onItemClick里判断点击的是表情还是删除键。页面切换时记得更新底部指示器的选中态否则用户滑到第二页都不知道自己在哪页。2.3 尺寸适配表情图标大小不是死值很多新手直接在布局里写死表情ImageView的大小比如48dp换了屏幕密度或者用户在系统设置里调整了显示大小面板看起来就会很别扭。推荐做法图标尺寸用dp转px且与EditText的行高挂钩。比如EditText文字大小是16sp表情面板图标可以按textSize * 1.2这个比例来设置既保证显示协调又不会因为用户调整字号而变形。注意在插入ImageSpan时也需要动态获取EditText的textSize来计算图片的bounds这样表情在输入框里才会和文字在同一视觉高度上不会出现表情比文字大一圈或者小一圈的尴尬情况。3. 核心代码实现插入、删除与键盘切换3.1 在光标处插入Unicode EmojiUnicode Emoji本质是字符所以插入逻辑很简单核心就几步获取光标位置、替换选中区域或直接插入。private fun insertUnicodeEmoji(editText: EditText, emoji: String) { val editable editText.text val start editText.selectionStart.coerceAtLeast(0) val end editText.selectionEnd.coerceAtLeast(0) if (start end) return if (start end) { editable.insert(start, emoji) } else { editable.replace(min(start, end), max(start, end), emoji) } }这里有个细节很多代码会忽略用户选中文本的情况直接调editable.insert(start, emoji)导致用户高亮了一段文字再点表情时后面的文字被顶到新位置体验很糟糕。用replace处理选中区域才是正确写法。另外插入之后还有个隐含问题光标位置会自动移到插入内容后面EditText默认的Selection行为能处理好但如果你在TextWatcher里有其他逻辑要注意不要在afterTextChanged里再次修改光标否则很容易造成光标闪跳。3.2 插入图片表情ImageSpan与整体删除逻辑图片表情在EditText里是个ImageSpan它挂在字符上。我的做法是插入一个占位字符串比如[微笑]再把ImageSpan设置到这个字符串的范围内。class EmojiImageSpan(drawable: Drawable, val code: String) : ImageSpan(drawable, ImageSpan.ALIGN_BASELINE) private fun insertImageEmoji(editText: EditText, emoji: EmojiItem) { val drawable ContextCompat.getDrawable(this, emoji.resId) ?: return val size (editText.textSize * 1.2).toInt() drawable.setBounds(0, 0, size, size) val placeholder emoji.code val spannable SpannableString(placeholder) val span EmojiImageSpan(drawable, emoji.code) spannable.setSpan(span, 0, placeholder.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) val editable editText.text val start editText.selectionStart.coerceAtLeast(0) val end editText.selectionEnd.coerceAtLeast(0) editable.replace(min(start, end), max(start, end), spannable) }这个EmojiImageSpan是我自定义的子类额外保存了表情的code。这样做的好处是后面序列化输出时遍历所有EmojiImageSpan就能拿到该表情的逻辑标识符不需要再做范围匹配。删除是重点。用户按退格键时如果光标刚好停在图片表情后面系统默认只会删除“一个字符”但一个图片表情的占位符是[微笑]这种长度大于1的字符串直接删会造成表情“残缺”甚至字符删了一半。正确的做法是拦截删除键事件判断光标前是否有完整的ImageSpanoverride fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.keyCode KeyEvent.KEYCODE_DEL event.action KeyEvent.ACTION_DOWN) { val editText binding.etInput val editable editText.text val selectionStart editText.selectionStart val selectionEnd editText.selectionEnd // 有选中区域时交给系统默认处理 if (selectionStart 0 selectionEnd selectionStart) { return super.dispatchKeyEvent(event) } val cursor selectionStart.coerceAtLeast(0) if (cursor 0) { val spans editable.getSpans(cursor - 1, cursor, EmojiImageSpan::class.java) if (spans.isNotEmpty()) { val spanStart editable.getSpanStart(spans[0]) editable.delete(spanStart, cursor) return true } } } return super.dispatchKeyEvent(event) }关键点在于先用getSpans(cursor - 1, cursor, ...)去探测光标前是否有图片表情如果有就删除这个ImageSpan覆盖的完整区间然后消费掉这个删除事件不再触发系统默认删除逻辑。我这里是在当前Activity的dispatchKeyEvent里做的如果你用的是自定义EditText组件也可以重写onKeyDown或在InputConnection层面拦截。但dispatchKeyEvent对物理键盘和输入法删除键的覆盖范围更全实测在第三方输入法下也表现稳定。3.3 表情面板与软键盘的切换与生命周期处理这一块如果处理不好UI体验直接崩盘。常见现象是点一下表情按钮软键盘还没完全收起表情面板已经顶上来界面跳一下或者收起表情面板时软键盘没有自动弹出。我的方案是在根布局上添加OnGlobalLayoutListener监听软键盘高度变化。private fun initKeyboardDetector() { val rootView binding.root rootView.viewTreeObserver.addOnGlobalLayoutListener { val rect Rect() rootView.getWindowVisibleDisplayFrame(rect) val screenHeight rootView.rootView.height val keyboardHeight screenHeight - rect.bottom if (keyboardHeight screenHeight / 3) { isKeyboardVisible true } else { isKeyboardVisible false } } }点击表情按钮时的切换逻辑可以这样设计binding.ivEmoji.setOnClickListener { val imm getSystemService(InputMethodManager::class.java) if (isKeyboardVisible) { imm.hideSoftInputFromWindow(binding.etInput.windowToken, 0) binding.emojiPanel.visibility View.VISIBLE } else { binding.emojiPanel.visibility View.GONE imm.showSoftInput(binding.etInput, InputMethodManager.SHOW_IMPLICIT) } }这里有个经验之谈隐藏软键盘到显示表情面板之间通常有几百毫秒的动画时间如果直接设置VISIBLE视觉上会显得面板弹得很快、很突兀。一个比较自然的做法是用postDelayed延迟约300ms再显示面板具体时长可以根据系统键盘动画实测调整。还要注意如果表情面板和软键盘都在windowSoftInputMode默认的adjustResize模式下工作软键盘弹出会压缩布局容易把表情面板顶上去又压缩导致面板高度和键盘高度不一致。我的做法是给表情面板设置一个固定高度通常等于软键盘平均高度用KeyboardHeightProvider获取这样切换时面板高度稳定视觉不跳动。4. 常见问题排查与表情序列化存储4.1 发送消息时如何把表情转成可传输的字符串EditText里已经用ImageSpan显示了表情图片但把EditText.getText().toString()拿出来时ImageSpan并不会被“翻译”回[微笑]这样的代码拿到的只会是一串占位的方括号字符。所以必须手写转换逻辑。我的做法是遍历Editable里的所有EmojiImageSpan拿到每个span的code在转换时用回调接口输出。private fun convertToMessageString(editable: Editable): String { val builder StringBuilder() val spans editable.getSpans(0, editable.length, EmojiImageSpan::class.java) var lastIndex 0 for (span in spans) { val spanStart editable.getSpanStart(span) val spanEnd editable.getSpanEnd(span) builder.append(editable.subSequence(lastIndex, spanStart)) builder.append(span.code) lastIndex spanEnd } builder.append(editable.subSequence(lastIndex, editable.length)) return builder.toString() }反过来接收端渲染历史消息时通过正则匹配\[[^\]]\]这种占位符再替换成对应的ImageSpan图片即可。private fun fromMessageString(editText: EditText, content: String) { val pattern Pattern.compile(\\[[^\\]]\\]) val matcher pattern.matcher(content) val spannable SpannableString(content) while (matcher.find()) { val code matcher.group() val resourceId EmojiManager.instance().getResIdByCode(code) if (resourceId ! 0) { val drawable ContextCompat.getDrawable(this, resourceId) val size (editText.textSize * 1.2).toInt() drawable?.setBounds(0, 0, size, size) val span EmojiImageSpan(drawable!!, code) spannable.setSpan(span, matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } } editText.setText(spannable) }这套方案的核心思路就是“占位符代码”作为中间层EditText内部用Spannable展示图片对外传输用字符串代码两边通过code做映射。4.2 常见问题速查表整理一下我在开发过程中遇到的典型问题基本都是上线后才暴露的提前避开能省不少事。现象根本原因解决方案输入框里的表情图片模糊图片是位图尺寸被直接拉伸使用textSize * 1.2动态计算不要写死dp值换行后光标跑到表情前面插入时未考虑光标偏移或算法写错先用editText.setSelection定位再插入删除键删到表情字符的一半没有拦截KEYCODE_DEL做整体删除用getSpans(cursor - 1, cursor, EmojiImageSpan)判断软键盘弹出把表情面板顶变形面板高度未固定和键盘高度不一致给面板设置固定高度和软键盘平均高度对齐发送消息后表情全部丢失直接从Editable转String没转占位符循环遍历ImageSpan替换成code表情面板在API低版本显示空白GridView复用问题或资源加载失败确保资源加载成功后再设置Adapter并检查ViewHolder复用4.3 生命周期与内存泄漏的坑表情面板一般持有很多图标资源如果直接在Activity里引用它旋转屏幕或退出页面时没释放引用很容易内存泄漏。我建议把表情面板相关的资源加载、图标解码放到一个独立的Manager类里让Manager持有Application级别的Context而不是Activity级别的。另外EditText的TextWatcher和键盘监听器在onDestroy时一定要移除尤其是OnGlobalLayoutListener这个监听器如果忘记移除页面销毁后依然会收到全局布局变化回调严重时会导致内存泄漏甚至崩溃。我从API 21到API 34的设备上实测过这套方案表现稳定。如果你只是做一个需要简单表情输入的界面可以直接用Unicode Emoji方案成本最低如果有产品定制需求就按本文的ImageSpan方案扩展。最后提醒一点千万不要为了省事在EditText里用append()插入表情字符串因为append()永远插在尾部用户在中间编辑时表情会跑到最后面这个bug用户很容易遇到反馈率极高。本文还有配套的精品资源点击获取