简介:一份面向Android初学者的留言板小Demo,覆盖发表留言、删除留言与动态显示等场景,适合希望把Activity、SQLite与列表刷新串起来的读者。示例围绕Activity展开,从EditText、Button的UI布局开始,完成留言输入、校验、提交与结果提示;数据层使用SQLite保存记录,并在删除时通过SQL语句定位目标记录、执行删除并同步列表;展示层借助Adapter与ListView/RecyclerView实现数据绑定,配合notifyDataSetChanged完成增删后的实时刷新。这一过程涵盖了Activity生命周期、事件监听、SQLite增删查改和列表适配器同步等关键知识点,同时也涉及删除确认、错误处理与基础交互优化。资源包为RAR格式,整体约6.09MB,页面未单列文件总数与类型明细,以解压后的工程结构为准。已有1196人学习/下载,适合想快速上手Android数据存储与界面联动的开发者,也可作为课程设计或自学练手素材。 做Android开发这些年,我见过太多人问同一个问题:想快速入门,第一个练手项目做什么好?我的答案一直很固定——做一个留言板小demo。别看它名字里带个“小”,这个项目把Android开发的骨架全撑起来了:Activity、布局文件、RecyclerView列表、适配器、事件监听、数据存储,这些入门必须掌握的东西,一个都不少。如果你正准备用Android Studio搭建自己的第一个应用,或者已经会了一些零散知识点但不知道怎么串起来,这个留言板demo就是最适合动手的练手材料。它走通的链路很清晰:用户输入一句留言,点发送,新留言出现在列表顶部,重启App之后数据还在。就这一条完整链路,足够让你把Android应用从界面到逻辑完整走一遍。
我在带新人或者自己练手的时候,会反复用这个项目来验证对基础知识的理解程度。有些东西看着简单,真上手写才会发现细节一堆。这篇文章就把整个demo从环境准备、界面设计到核心代码、常见坑位全部拆开讲清楚,照着走一遍,你也能在半小时内跑出一个能用的留言板。
1. 这个留言板demo的定位与技术选型
1.1 为什么选留言板作为练手项目
留言板最吸引我的地方在于:它的业务逻辑非常朴素,但交互链路极其完整。输入内容、提交数据、刷新列表、持久化存储,这四步恰好对应了Android应用开发里最常打交道的四类操作——控件交互、界面更新、数据管理和生命周期处理。比起计算器、待办清单这类同样热门的demo,留言板的体验更接近真实产品:用户会持续产生内容,列表会不断增长,你需要考虑新数据怎么插入、老数据怎么保存,这种“持续演进”的感觉是静态demo给不了的。
从难度梯度上看,留言板也足够友好。你不需要一开始就碰网络请求、数据库设计、架构模式这些东西,只需要专注在单机本地功能的实现上。等把这个demo完全跑通,你对RecyclerView的工作机制、适配器的刷新逻辑、SharedPreferences的存储方式都会形成直观认识,这时候再往Room、ViewModel、LiveData那些方向上深入,心里就有底了。很多新人一上来就照着大型项目模仿,结果被各种抽象概念砸晕,最后连一个简单的界面都没跑起来,反倒是这种小demo能循序渐进地把基础夯扎实。
1.2 开发环境与依赖配置
我用的是Android Studio Hedgehog 2023.1.1稳定版,AGP版本8.2,Kotlin版本1.9.x。这个组合目前比较稳,新建工程的时候模板默认就是Kotlin + 新版Gradle配置,不用手动折腾太多东西。如果你电脑上装的是更高版本的Android Studio也没关系,核心代码逻辑是通用的,只要AGP和Gradle版本能匹配上就行。
项目配置方面,我在build.gradle.kts(模块级)里加了这样几个依赖:
dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") implementation("androidx.recyclerview:recyclerview:1.3.2") }compileSdk用34,minSdk用24。minSdk设到24主要是为了省心,覆盖了绝大多数现役设备,同时不用处理老版本系统上的一些兼容问题。语言选Kotlin而不是Java,这个我不过多解释——现在的Android官方文档、社区示例、第三方库几乎都优先支持Kotlin,你早晚要转到Kotlin上来,不如一开始就用。整个demo涉及的基础依赖就上面三个,其他全部用Android SDK自带的能力,让关注点完全集中在核心逻辑上。
2. 界面布局与数据模型设计
2.1 主界面布局的搭建思路
留言板的主界面从上到下分三层:顶部标题栏、中间留言列表、底部输入栏。我用一个垂直方向的LinearLayout来承载这三部分,中间列表部分用RecyclerView,并设置layout_weight=1,让它自动撑满剩余空间。底部输入栏是一个横向LinearLayout,左边EditText输入内容,右边Button触发发送。
布局文件activity_main.xml如下:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:background="#F5F6F8"> <TextView android:id="@+id/tvTitle" android:layout_width="match_parent" android:layout_height="48dp" android:gravity="center" android:text="留言板" android:textSize="18sp" android:textColor="#333333" android:background="#FFFFFF" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rvMessages" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:padding="12dp" android:clipToPadding="false" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="10dp" android:background="#FFFFFF"> <EditText android:id="@+id/etInput" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:hint="说点什么..." android:maxLines="3" android:inputType="textMultiLine" /> <Button android:id="@+id/btnSend" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="发送" /> </LinearLayout> </LinearLayout>这里有两个细节值得说明。第一,为什么列表不用ScrollView嵌套LinearLayout,而是用RecyclerView?因为RecyclerView自带ViewHolder回收复用机制,当留言条数变多时,它只创建屏幕范围内可见的item,滑动过程中不断复用旧视图,性能比ScrollView方案好太多。第二,EditText的inputType我设成了textMultiLine,这样用户输入长文本时能自动换行,同时配合maxLines=3限制最大高度,避免输入框把列表区域挤压得过于狭窄。
2.2 留言数据模型与列表项设计
一条留言需要包含两个核心字段:留言内容和发布时间。这里我用Kotlin的data class来定义,简单清晰:
data class Message( val content: String, val timestamp: Long )time戳用System.currentTimeMillis()生成,保存的是毫秒值,展示的时候再按需格式化。这样存的是原始数据,想显示成什么格式都很方便,不会在存储阶段就把展示逻辑写死。
列表项item_message.xml相对简单,我用了垂直方向的LinearLayout,上面显示留言内容,下面显示格式化后的时间:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="12dp" android:layout_marginBottom="10dp" android:background="#FFFFFF"> <TextView android:id="@+id/tvContent" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="#222222" /> <TextView android:id="@+id/tvTime" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="6dp" android:textSize="12sp" android:textColor="#999999" /> </LinearLayout>这里我直接把背景色设成了白色,配合根布局的浅灰背景,每条留言自然形成了卡片式分隔效果。如果你想要更精致的圆角卡片,可以专门写一个shape drawable作为背景,这里为了demo简洁就没做这层封装。
3. 核心代码实现与完整流程
3.1 适配器实现与列表刷新
RecyclerView的适配器是整个demo里最容易写错的地方。我先把完整代码贴出来,然后逐个方法解释:
class MessageAdapter( private val messages: MutableList<Message> ) : RecyclerView.Adapter<MessageAdapter.ViewHolder>() { class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val tvContent: TextView = itemView.findViewById(R.id.tvContent) val tvTime: TextView = itemView.findViewById(R.id.tvTime) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_message, parent, false) return ViewHolder(view) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val message = messages[position] holder.tvContent.text = message.content holder.tvTime.text = formatTime(message.timestamp) } override fun getItemCount(): Int = messages.size private fun formatTime(timestamp: Long): String { val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) return sdf.format(Date(timestamp)) } }写Adapter的时候,最容易犯的错误是搞混onCreateViewHolder和onBindViewHolder的分工。onCreateViewHolder只在需要创建新视图时调用,负责把布局文件变成View对象;onBindViewHolder则负责把数据绑定到视图上。ViewHolder存在的意义就是缓存item里的子View引用,避免每次绑定数据都要重新findViewById一遍,这在高频刷新时会明显影响性能。另外注意,我拿到的是外部传入的同一个MutableList引用,所以外部List增加数据后,只要调用notify相关方法,Adapter就能感知并刷新界面。
3.2 发布留言与持久化存储
MainActivity是整个demo的调度中心。初始化、加载历史数据、绑定点击事件、保存数据都在这里完成。看代码:
class MainActivity : AppCompatActivity() { private lateinit var rvMessages: RecyclerView private lateinit var etInput: EditText private lateinit var btnSend: Button private lateinit var adapter: MessageAdapter private val messages = mutableListOf<Message>() private val prefs by lazy { getSharedPreferences("message_board", MODE_PRIVATE) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) rvMessages = findViewById(R.id.rvMessages) etInput = findViewById(R.id.etInput) btnSend = findViewById(R.id.btnSend) loadMessages() adapter = MessageAdapter(messages) rvMessages.layoutManager = LinearLayoutManager(this) rvMessages.adapter = adapter btnSend.setOnClickListener { addMessage() } } private fun addMessage() { val content = etInput.text.toString().trim() if (content.isEmpty()) { Toast.makeText(this, "留言内容不能为空", Toast.LENGTH_SHORT).show() return } messages.add(0, Message(content, System.currentTimeMillis())) adapter.notifyItemInserted(0) rvMessages.scrollToPosition(0) etInput.setText("") saveMessages() } private fun saveMessages() { val jsonArr = JSONArray() for (msg in messages) { val obj = JSONObject() obj.put("content", msg.content) obj.put("timestamp", msg.timestamp) jsonArr.put(obj) } prefs.edit().putString("messages", jsonArr.toString()).apply() } private fun loadMessages() { val jsonStr = prefs.getString("messages", "[]") val jsonArr = JSONArray(jsonStr) for (i in 0 until jsonArr.length()) { val obj = jsonArr.getJSONObject(i) messages.add(Message(obj.getString("content"), obj.getLong("timestamp"))) } } }先说数据存储。demo阶段我选了SharedPreferences + JSON字符串的方案,没有上SQLite或者Room。原因很直接:留言条数不多时,把整个列表序列化成一个字符串存起来,实现成本最低,读出来直接JSONArray解析就能恢复。用SQLite当然也可以,但需要自己写数据库帮助类,处理升级、事务、游标关闭这些细节,对新手来说概念负担太重。Room更是要把注解、DAO、数据库类全部建立起来,作为demo会喧宾夺主。等你要做正式的留言板产品,再换成Room完全来得及,核心界面逻辑不用大改。
再说列表刷新。这里我用的是notifyItemInserted(0)而不是notifyDataSetChanged(),同时把新留言插入到List的最前面。这样新发的留言会出现在顶部,配合rvMessages.scrollToPosition(0),用户能立刻看到自己刚发的内容。notifyItemInserted比notifyDataSetChanged精细得多,它只通知Adapter在位置0插入了一条数据,系统会走item动画,不会把整个列表全部重绘。这个细节在数据量小的时候看不出来,但养成用细粒度通知的习惯,对你后面做复杂列表会很有帮助。
3.3 启动加载历史留言
loadMessages()在onCreate里执行,顺序放在创建Adapter之前。这样保证messages列表在初始化Adapter时已经有了数据,Adapter的getItemCount不会返回0。有个小坑:SharedPreferences的getString方法如果对应key不存在,返回的是null,所以我在调用prefs.getString("messages", "[]")时给了默认值"[]",避免JSONArray的构造函数拿到null引发崩溃。
解析JSON的时候,org.json这套API虽然写起来没有Gson那么简洁,但它不依赖任何第三方库,Android SDK自带,用来存留有一个轻量数据集合非常合适。如果你项目里本来就已经引入了Gson,用Gson更省事,直接TypeToken把JSON字符串反序列化成List,代码还能少一半。但作为一个刻意保持最小依赖的demo,我用原生JSONArray的方式,也能让初学者看清序列化和反序列化的完整数据流。
安卓界还有一个细节我建议顺手加上:给EditText设置监听,当用户点击键盘上的发送键时也能提交留言,这比只点按钮更符合聊天产品的手感。Kotlin写起来很简洁:
etInput.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_SEND) { addMessage() true } else { false } }注意给EditText设置android:imeOptions="actionSend",并把inputType设为textMultiLine时,部分输入法不会显示发送键,这是输入法的自身行为,不影响通过按钮提交。
4. 常见问题与避坑经验
4.1 列表不更新,数据“没生效”
这是RecyclerView新手最容易踩的坑。现象是添加数据后,列表界面毫无变化,或者要滑动一下才出现新item。原因基本都出在忘了调notify相关方法。你改了messages集合,只是改了内存里的数据,Adapter并不知道数据变了,必须主动通知它。记住一句话:修改List之后,一定要调用adapter.notifyDataSetChanged()或者更细粒度的notifyItemInserted() / notifyItemRemoved()。
还有另一种情况:你调用了notifyItemInserted但位置对不上。比如数据插到了List末尾,却通知插入在位置0,列表就会显示异常甚至崩溃。排查这类问题时,我习惯在onBindViewHolder里打一个Log,把position和数据内容打出来,一眼就能看出谁和谁没对上。
4.2 输入法弹出遮挡住输入框
底部输入栏比较容易被软键盘顶上来或者直接盖住,这取决于AndroidManifest里对windowSoftInputMode的配置。我推荐用adjustResize:
<activity android:name=".MainActivity" android:windowSoftInputMode="adjustResize" />adjustResize会在键盘弹出时压缩Activity的可用高度,让布局重新布局,底部输入栏自动顶到键盘上方。如果你用adjustPan,系统会把整个窗口上移,在输入框位于底部时表现尚可,但界面顶部的标题会被推出去,体验不太好。
不过说实话,我在一些深度定制的国产ROM上遇到过adjustResize不生效的情况,键盘弹出时布局纹丝不动,输入框被完全遮住。应急方案是监听键盘弹出事件,动态给列表底部加一个padding或者直接把输入栏平移上去。要做这个监听,可以用ViewTreeObserver.OnGlobalLayoutListener监听布局变化,对比根视图的高度差判断键盘状态。这个方案具备一定复杂度,demo阶段先记着有这个坑就好。
4.3 SharedPreferences读写容易踩的坑
第一个坑是空字符串。如果读取出来的jsonStr是空字符串,直接JSONArray(jsonStr)会抛异常。所以读取时要做好兜底:要么在读的时候判断isEmpty,要么默认值就传"[]"。第二个坑是apply()和commit()的选择。apply是异步写磁盘,不会阻塞主线程,但写入时机不确定;commit是同步写,会阻塞主线程直至写入完成。demo里数据量小,用apply完全没问题,千万别在主线程用commit频繁提交大块数据。
还有一个坑级别比较隐蔽:如果你在多个位置修改SharedPreferences,记得用同一个key和同一个SharedPreferences实例。我见过有项目在Application里创建了一个SharedPreferences,在某个Activity里又用另一个文件名创建,结果数据读出来永远对不上。统一集中在同一个管理类里操作,能省掉很多玄学问题。
5. 留给你的扩展思路
5.1 从demo到完整应用的三个方向
这个留言板跑通之后,再往下走的方向就很清晰了。第一,把数据层换掉:引入Room数据库,加一个Repository仓库类管理数据源,结合ViewModel和LiveData做响应式更新,这一步是往正规架构迈进的关键。第二,加交互能力:长按留言可以删除,点击留言可以编辑,再给每条留言加上头像和昵称,整体就更像一个社区产品。第三,接入网络:用Retrofit对接后端接口,让留言能够跨设备同步,这个demo就真正变成一个线上应用了。这三个方向难度递增,你可以按自己的精力选一个深入。
5.2 我在这个demo里体会最深的一点
做这个小demo最让我感慨的不是某个API怎么调用,而是“完整走通一条链路”的价值。很多新手学Android,今天看一个RecyclerView教程,明天看一个数据存储文章,每个知识点都“看懂了”,但从来没把这些串联起来跑一遍。这个留言板虽然只有几百行代码,却逼着你把界面、数据、适配器、存储全流程亲手走一遍,所有知识点在动手过程中自然打通。我强烈建议你不要只复制粘贴代码,而是每写完一个模块,停下来想想如果去掉某一行会怎样,改一个参数又会怎样。把demo折腾坏了再修好,学到的东西比顺利跑通多一倍。说到底,练手项目的价值不在于“多高级”,而在于让你用自己的手验证自己的理解。
本文还有配套的精品资源,点击获取