很多初学者学 Android 第一件事就是打开一本教材背四大组件、记生命周期回调,结果概念背了一堆,真到打开 Android Studio 写项目时,还是不知道怎么把界面和逻辑串起来。这其实是学习路径设计的问题。如果换成“先抛出一个能运行、能看到效果的小任务”,再围绕任务去补充知识点,学习效率会高很多。
在童长飞老师的任务驱动式 Android 移动开发基础教学体系中,核心思路正是这个:每个阶段只做一个可验证的任务,用任务结果带动知识记忆。本文结合这套思路,以“迷你记事本 App”为例,把 Android 开发基础拆成 5 个连贯任务,从界面搭建、页面跳转、列表展示,到 SQLite 数据存储和增删改查,逐步完成一个可运行、可打包的真实小程序。无论你是刚接触 Android 的学生,还是想带项目的老师,这套流程都值得完整走一遍。
1. 为什么学 Android 要从任务驱动开始
1.1 传统学习的痛点
传统 Android 入门的顺序一般是:先讲抽象类、接口、四大组件概念,再讲布局文件结构、控件属性,最后才让学生动手写一个 Demo。这种路径的问题在于:知识被拆得太碎,没有“主线任务”牵引。学生听的时候觉得都会,但合上教材之后,仍然不知道一个 App 从创建到运行到底经历了什么。
任务驱动式教学最大的不同,是让“完成一个真实功能”成为学习的锚点。每学一个知识点,都是为了解决面前这个任务里出现的具体问题。比如“为什么页面跳转要写 Intent?”“为什么列表数据变了界面要刷新?”——这些问题不是老师硬塞的,而是学生在完成任务时自然会遇到的。
1.2 任务驱动式设计原则
在童长飞老师的任务驱动式教学案例中,任务设计遵循几个基本原则:
- 任务可运行:每一步做完,都能生成一个可安装、可点击的项目,而不是只写完一段孤立代码。
- 难度阶梯化:第一个任务只做静态界面,第二个任务加入事件和跳转,第三个任务引入数据列表,逐步增加复杂度。
- 旧知识在新任务中复现:后面任务会反复用到前面的技能,让知识在重复使用中固化。
- 每个任务解决一个真实问题:不是“写一个布局”,而是“做一个登录界面”;不是“学一下数据库 API”,而是“把记事内容存下来并显示出来”。
这种设计思路非常适合基础教学,也适合自学者给自己安排学习计划。
1.3 本文案例概览
本文要完成的迷你记事本 App,由以下 5 个任务组成:
| 任务 | 核心内容 | 掌握技能 |
|---|---|---|
| 任务 1 | 创建工程与登录界面 | 工程结构、XML 布局、控件绑定、Toast |
| 任务 2 | 登录跳转与页面生命周期 | Intent、Activity 跳转、生命周期与日志 |
| 任务 3 | 记事列表展示 | RecyclerView、Adapter、ViewHolder、LayoutManager |
| 任务 4 | SQLite 数据存储 | SQLiteOpenHelper、ContentValues、Cursor、CRUD |
| 任务 5 | 添加、编辑、删除记事 | Dialog 交互、Adapter 回调、列表刷新 |
每个任务都以前一个任务为基础,最终得到一个具备“登录 → 查看列表 → 新增 → 编辑 → 删除”完整闭环的 App。
2. 开发环境准备
2.1 软件与版本说明
本文的代码运行环境如下,版本可根据你本机实际情况调整,重点演示配置思路和代码写法。
操作系统:Windows 10 / macOS / Linux 均可 IDE:Android Studio(推荐使用 2023.1 及以上版本) JDK:JDK 17 Android SDK:compileSdk 34,minSdk 24 构建工具:Gradle 8.2 左右(Android Studio 自动管理) 开发语言:Java 或 Kotlin,本文以 Java 为主需要注意,Android Studio 的版本更新速度很快,不同版本生成的工程模板略有差异。如果你的 Gradle 版本或 SDK 版本不同,一般不影响本文代码,只需在创建工程时选择 Empty Views Activity(空视图 Activity)模板即可。
2.2 创建项目
打开 Android Studio,点击 New Project,选择 Empty Views Activity,填写项目信息:
Name:Notebook Package name:com.example.notebook Save location:自定义 Language:Java Minimum SDK:API 24(Android 7.0)创建完成后,建议先运行一次空工程,确保模拟器或真机环境正常。本文后续所有文件路径,都以这个项目为基础。
2.3 项目结构说明
创建完成后,需要重点关注以下文件:
app/ ├── src/main/ │ ├── java/com/example/notebook/ │ │ ├── LoginActivity.java │ │ ├── MainActivity.java │ │ ├── Note.java │ │ ├── NoteAdapter.java │ │ ├── DBHelper.java │ │ └── NoteDao.java │ ├── res/ │ │ ├── layout/ │ │ │ ├── activity_login.xml │ │ │ ├── activity_main.xml │ │ │ └── item_note.xml │ │ └── values/ │ └── AndroidManifest.xml └── build.gradle(Module 级别的配置)后面的任务会按顺序往这个结构里填充内容。第一个任务只涉及登录界面的两个文件。
3. 任务 1:创建登录界面
3.1 任务目标
第一个任务要做的事情很单纯:显示一个登录界面,包含用户名输入框、密码输入框和登录按钮。用户在界面上输入内容后,点击按钮能收到提示。
这个任务的任务点集中在:认识布局文件、理解 LinearLayout 布局、掌握 EditText 和 Button 的基本用法。完成之后,你会觉得 Android 界面其实就是用 XML 描述出来的视图结构。
3.2 编写登录布局
文件路径:app/src/main/res/layout/activity_login.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:gravity="center_horizontal" android:padding="24dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="60dp" android:text="迷你记事本" android:textSize="28sp" android:textStyle="bold" /> <EditText android:id="@+id/et_username" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="30dp" android:hint="请输入用户名" android:inputType="text" /> <EditText android:id="@+id/et_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:hint="请输入密码" android:inputType="textPassword" /> <Button android:id="@+id/btn_login" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="登录" /> </LinearLayout>这段布局的核心结构是:
- 根布局是 LinearLayout,
orientation="vertical"表示子控件从上到下垂直排列。 - EditText 是输入框,
inputType="textPassword"表示密码输入时显示为圆点。 - 每个控件都设置了
id,用于在 Java 代码中通过findViewById找到它。
在基础教学中,这里还有一个容易被忽略的细节:wrap_content让控件包裹内容,match_parent让控件撑满父容器。如果对这两个属性理解不透,后面做复杂布局时会出现控件“莫名消失”或“撑满整个屏幕”的问题。
3.3 编写登录页面代码
文件路径:app/src/main/java/com/example/notebook/LoginActivity.java
package com.example.notebook; import android.os.Bundle; import android.text.TextUtils; import android.widget.Button; import android.widget.EditText; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; public class LoginActivity extends AppCompatActivity { private EditText etUsername; private EditText etPassword; private Button btnLogin; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); etUsername = findViewById(R.id.et_username); etPassword = findViewById(R.id.et_password); btnLogin = findViewById(R.id.btn_login); btnLogin.setOnClickListener(v -> { String username = etUsername.getText().toString().trim(); String password = etPassword.getText().toString().trim(); if (TextUtils.isEmpty(username) || TextUtils.isEmpty(password)) { Toast.makeText(LoginActivity.this, "用户名和密码不能为空", Toast.LENGTH_SHORT).show(); return; } if ("admin".equals(username) && "123456".equals(password)) { Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(LoginActivity.this, "用户名或密码错误", Toast.LENGTH_SHORT).show(); } }); } }这段代码做了三件事:
- 通过
setContentView把 XML 布局加载到页面中。 - 通过
findViewById绑定控件实例。 - 给登录按钮设置点击监听,在监听中判断输入是否为空、账号密码是否正确。
这里要强调一个安全边界:把账号密码写死在代码里,仅仅是教学演示,用来体现“输入校验”和“点击事件”的完整流程。真实项目中,登录校验必须放到服务端完成,客户端不能保存明文密码。
3.4 修改启动入口
要让系统启动时先打开登录页,需要修改AndroidManifest.xml:
<activity android:name=".LoginActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>同时,把 MainActivity 的入口标签移除,否则应用启动时会同时存在两个可启动 Activity,系统会弹窗让你选择启动哪个,不符合预期。
运行效果是:输入为空点击登录,提示“用户名和密码不能为空”;输入 admin / 123456,提示“登录成功”;其他组合提示“用户名或密码错误”。
这时候,第一个任务就算完成了。你已经完成了 Android 开发中最基础也是最重要的“界面 → 绑定 → 事件”闭环。
4. 任务 2:页面跳转与 Activity 生命周期
4.1 任务目标
任务 1 运行后,即使登录成功,也只是弹一个 Toast,没有进入真正的 App 主页。任务 2 的核心任务就是解决“登录成功后如何跳到主页面”的问题。
这里会引入两个重要概念:Intent 和 Activity 生命周期。
4.2 创建主页面布局
文件路径:app/src/main/res/layout/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:padding="16dp"> <TextView android:id="@+id/tv_welcome" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="欢迎使用迷你记事本" android:textSize="20sp" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_notes" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:layout_marginTop="12dp" /> </LinearLayout>这里提前加入了 RecyclerView 容器,但它现在还没有数据。任务 3 会专门填充它。主页面布局中使用了layout_weight="1",让 RecyclerView 占满剩余空间,这个用法在基础教学里很常见,建议顺手掌握。
4.3 修改 MainActivity
文件路径:app/src/main/java/com/example/notebook/MainActivity.java
package com.example.notebook; import android.os.Bundle; import android.util.Log; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); TextView tvWelcome = findViewById(R.id.tv_welcome); String username = getIntent().getStringExtra("username"); if (username != null) { tvWelcome.setText("欢迎使用迷你记事本," + username); } Log.d(TAG, "onCreate 执行"); } @Override protected void onStart() { super.onStart(); Log.d(TAG, "onStart 执行"); } @Override protected void onResume() { super.onResume(); Log.d(TAG, "onResume 执行"); } @Override protected void onPause() { super.onPause(); Log.d(TAG, "onPause 执行"); } @Override protected void onStop() { super.onStop(); Log.d(TAG, "onStop 执行"); } @Override protected void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy 执行"); } }这段代码的重点是生命周期方法。每个方法里都加入了 Log,方便在 Logcat 面板观察 Activity 从创建到销毁的过程。如果你不理解生命周期,请务必亲手跑一次,然后在模拟器上点击返回键,再重新进入页面,对比日志输出顺序。
4.4 在登录页中实现跳转
回到 LoginActivity,在登录校验通过的地方加入 Intent 跳转:
if ("admin".equals(username) && "123456".equals(password)) { Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show(); Intent intent = new Intent(LoginActivity.this, MainActivity.class); intent.putExtra("username", username); startActivity(intent); finish(); }注意两点:
putExtra用来携带登录用户名,主页面通过getIntent().getStringExtra("username")读取。finish()表示关闭当前 LoginActivity 页面。因为登录成功后,用户不应该再通过返回键回到登录页。
同时,在 LoginActivity 顶部补上 import:
import android.content.Intent;任务 2 完成后,你理解了 Activity 的“创建-启动-显示-暂停-停止-销毁”完整过程,也掌握了 Intent 的显式用法。这个知识点在 Android 开发里属于高频核心,后续所有功能页面之间协作都依赖它。
5. 任务 3:用 RecyclerView 展示记事列表
5.1 任务目标
登录成功并进入主页面后,页面上只有一行欢迎文字,没有实际内容。任务 3 的核心目标是:在 RecyclerView 中展示一组记事内容。
这个任务会引入三大概念:实体类(Model)、Adapter 适配器、ViewHolder 复用。这是 Android 列表开发的基础中的基础。
5.2 创建记事实体类
文件路径:app/src/main/java/com/example/notebook/Note.java
package com.example.notebook; public class Note { private int id; private String content; private String createTime; public Note() { } public Note(String content, String createTime) { this.content = content; this.createTime = createTime; } public int getId() { return id; } public void setId(int id) { this.id = id; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public String getCreateTime() { return createTime; } public void setCreateTime(String createTime) { this.createTime = createTime; } }实体类的本质就是“一条数据的模型”。后面从数据库读取数据时,每条查询结果都会封装成一个 Note 对象,放入 List,再交给 Adapter 显示。
5.3 创建列表条目布局
文件路径:app/src/main/res/layout/item_note.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="wrap_content" android:orientation="vertical" android:padding="16dp"> <TextView android:id="@+id/tv_content" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:text="记事内容" /> <TextView android:id="@+id/tv_time" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="6dp" android:textSize="12sp" android:textColor="#999999" android:text="创建时间" /> </LinearLayout>这个布局将来会作为列表中的每一行被反复创建。为了让视觉上区分不同条目,可以在根布局中设置一个背景色,比如android:background="#F5F5F5"。
5.4 编写 Adapter
文件路径:app/src/main/java/com/example/notebook/NoteAdapter.java
package com.example.notebook; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import java.util.List; public class NoteAdapter extends RecyclerView.Adapter<NoteAdapter.NoteViewHolder> { public interface OnItemClickListener { void onItemClick(Note note); void onItemLongClick(Note note); } private final List<Note> noteList; private OnItemClickListener listener; public NoteAdapter(List<Note> noteList) { this.noteList = noteList; } public void setOnItemClickListener(OnItemClickListener listener) { this.listener = listener; } @NonNull @Override public NoteViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_note, parent, false); return new NoteViewHolder(view); } @Override public void onBindViewHolder(@NonNull NoteViewHolder holder, int position) { Note note = noteList.get(position); holder.tvContent.setText(note.getContent()); holder.tvTime.setText(note.getCreateTime()); holder.itemView.setOnClickListener(v -> { if (listener != null) { listener.onItemClick(note); } }); holder.itemView.setOnLongClickListener(v -> { if (listener != null) { listener.onItemLongClick(note); } return true; }); } @Override public int getItemCount() { return noteList.size(); } static class NoteViewHolder extends RecyclerView.ViewHolder { TextView tvContent; TextView tvTime; NoteViewHolder(@NonNull View itemView) { super(itemView); tvContent = itemView.findViewById(R.id.tv_content); tvTime = itemView.findViewById(R.id.tv_time); } } }Adapter 在这里起“桥梁”作用:它把一组 Note 数据逐一绑定到 item_note 布局上。onCreateViewHolder 负责创建每一行的视图容器,onBindViewHolder 负责把数据填充到控件上。ViewHolder 的作用是缓存控件实例,避免滑动列表时反复查找控件,这是 RecyclerView 能流畅滑动的关键。
5.5 在 MainActivity 中组装列表
先把假数据写入 MainActivity:
package com.example.notebook; import android.os.Bundle; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.List; public class MainActivity extends AppCompatActivity { private RecyclerView rvNotes; private NoteAdapter noteAdapter; private List<Note> noteList = new ArrayList<>(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); TextView tvWelcome = findViewById(R.id.tv_welcome); String username = getIntent().getStringExtra("username"); if (username != null) { tvWelcome.setText("欢迎使用迷你记事本," + username); } rvNotes = findViewById(R.id.rv_notes); // 任务 3:先用模拟数据,任务 4 会替换为数据库数据 noteList.add(new Note("9 点开会讨论项目排期", "2025-01-10 08:30")); noteList.add(new Note("下午完成接口联调", "2025-01-10 10:20")); noteList.add(new Note("记得给老师发作业", "2025-01-10 14:05")); noteAdapter = new NoteAdapter(noteList); rvNotes.setLayoutManager(new LinearLayoutManager(this)); rvNotes.setAdapter(noteAdapter); } }运行项目,登录进入主页后,就能看到三条模拟记事。如果看不到列表,最常见的原因就是忘写rvNotes.setLayoutManager(...)。RecyclerView 没有 LayoutManager 时不会报错,但也不会显示任何内容——这个问题在任务驱动教学中很有价值,因为它逼着学生去查“为什么列表不显示”。
6. 任务 4:用 SQLite 保存记事数据
6.1 任务目标
任务 3 的列表数据是写死在代码里的,重启 App 后数据就没了。任务 4 的核心任务是:把记事保存到本地 SQLite 数据库,启动时从数据库读取历史数据。
这是 Android 基础教学中最重要的数据存储任务之一。
6.2 编写数据库帮助类
文件路径:app/src/main/java/com/example/notebook/DBHelper.java
package com.example.notebook; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import androidx.annotation.Nullable; public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "notebook.db"; private static final int DB_VERSION = 1; public DBHelper(@Nullable Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE IF NOT EXISTS note (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "content TEXT NOT NULL," + "create_time TEXT NOT NULL)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS note"); onCreate(db); } }SQLiteOpenHelper 是 Android 内置的数据库帮助类。onCreate 在数据库第一次创建时执行,负责建表;onUpgrade 在版本号增大时执行,教学案例中直接删表重建,但生产项目必须采用合理的迁移方案,否则用户数据会全部丢失。
6.3 编写数据访问层
文件路径:app/src/main/java/com/example/notebook/NoteDao.java
package com.example.notebook; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import java.util.ArrayList; import java.util.List; public class NoteDao { private final DBHelper dbHelper; public NoteDao(Context context) { dbHelper = new DBHelper(context); } public long insert(Note note) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("content", note.getContent()); values.put("create_time", note.getCreateTime()); long rowId = db.insert("note", null, values); db.close(); return rowId; } public List<Note> queryAll() { List<Note> list = new ArrayList<>(); SQLiteDatabase db = dbHelper.getReadableDatabase(); Cursor cursor = db.query("note", null, null, null, null, null, "id DESC"); while (cursor.moveToNext()) { Note note = new Note(); note.setId(cursor.getInt(cursor.getColumnIndexOrThrow("id"))); note.setContent(cursor.getString(cursor.getColumnIndexOrThrow("content"))); note.setCreateTime(cursor.getString(cursor.getColumnIndexOrThrow("create_time"))); list.add(note); } cursor.close(); db.close(); return list; } public int delete(int id) { SQLiteDatabase db = dbHelper.getWritableDatabase(); int rows = db.delete("note", "id = ?", new String[]{String.valueOf(id)}); db.close(); return rows; } public int update(Note note) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("content", note.getContent()); values.put("create_time", note.getCreateTime()); int rows = db.update("note", values, "id = ?", new String[]{String.valueOf(note.getId())}); db.close(); return rows; } }这里用了标准的 SQLite 增删改查接口。值得提醒的是,db.insert、db.delete、db.update都带有参数化查询能力,推荐使用?占位符传参,不要拼接 SQL 字符串,避免 SQL 注入风险。基础教学中如果养成了拼接 SQL 的习惯,到了后端开发或生产环境会非常危险。
6.4 修改 MainActivity 接入数据库
任务 4 中,MainActivity 要做两处改动:
- 把写死的模拟数据替换成数据库查询结果。
- 在界面中加入“新增记事”按钮,点击后弹窗输入内容并写入数据库。
先修改布局,把按钮加到顶部:
文件路径:app/src/main/res/layout/activity_main.xml
<Button android:id="@+id/btn_add" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="新增记事" />注意按钮要放在 TextView 下方。
然后修改 MainActivity:
package com.example.notebook; import android.app.AlertDialog; import android.os.Bundle; import android.text.TextUtils; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; public class MainActivity extends AppCompatActivity { private RecyclerView rvNotes; private NoteAdapter noteAdapter; private final List<Note> noteList = new ArrayList<>(); private NoteDao noteDao; private Button btnAdd; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); TextView tvWelcome = findViewById(R.id.tv_welcome); String username = getIntent().getStringExtra("username"); if (username != null) { tvWelcome.setText("欢迎使用迷你记事本," + username); } rvNotes = findViewById(R.id.rv_notes); btnAdd = findViewById(R.id.btn_add); noteDao = new NoteDao(this); // 第一次进入时加载数据库中的全部数据 noteList.addAll(noteDao.queryAll()); noteAdapter = new NoteAdapter(noteList); rvNotes.setLayoutManager(new LinearLayoutManager(this)); rvNotes.setAdapter(noteAdapter); btnAdd.setOnClickListener(v -> showAddDialog()); } private void showAddDialog() { EditText editText = new EditText(this); editText.setHint("请输入记事内容"); new AlertDialog.Builder(this) .setTitle("新增记事") .setView(editText) .setPositiveButton("保存", (dialog, which) -> { String content = editText.getText().toString().trim(); if (TextUtils.isEmpty(content)) { Toast.makeText(this, "内容不能为空", Toast.LENGTH_SHORT).show(); return; } String time = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) .format(new Date()); Note note = new Note(content, time); long rowId = noteDao.insert(note); if (rowId != -1) { note.setId((int) rowId); noteList.add(0, note); noteAdapter.notifyItemInserted(0); rvNotes.scrollToPosition(0); Toast.makeText(this, "保存成功", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show(); } }) .setNegativeButton("取消", null) .show(); } }到这里,任务 4 就形成了一个完整闭环:进入页面读取数据库 → 显示记事列表 → 点击新增按钮 → 弹窗输入内容 → 写入数据库 → 列表立即刷新。
这里有一个细节值得在教学中强调:新增成功后,不应该重新查一次数据库,而是直接把新 Note 插入 List 的头部,再调用notifyItemInserted(0)局部刷新。这样既避免重复查询,也避免整个列表闪烁。
7. 任务 5:编辑与删除记事
7.1 任务目标
任务 4 完成了数据的写入和读取,但用户还无法修改或删除已有的记事。任务 5 的最终目标是:单击某条记事弹出编辑对话框,长按某条记事弹出删除确认框。
这是整个迷你记事本项目的收尾任务,完成后,App 就是完整的增删改查(CRUD)应用。
7.2 在 MainActivity 中注册点击事件
在 MainActivity 的 onCreate 中,给 Adapter 设置监听器:
noteAdapter.setOnItemClickListener(new NoteAdapter.OnItemClickListener() { @Override public void onItemClick(Note note) { showEditDialog(note); } @Override public void onItemLongClick(Note note) { showDeleteDialog(note); } });7.3 实现编辑对话框
private void showEditDialog(Note note) { EditText editText = new EditText(this); editText.setText(note.getContent()); new AlertDialog.Builder(this) .setTitle("编辑记事") .setView(editText) .setPositiveButton("更新", (dialog, which) -> { String content = editText.getText().toString().trim(); if (TextUtils.isEmpty(content)) { Toast.makeText(this, "内容不能为空", Toast.LENGTH_SHORT).show(); return; } note.setContent(content); note.setCreateTime(new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) .format(new Date())); int rows = noteDao.update(note); if (rows > 0) { int position = noteList.indexOf(note); noteAdapter.notifyItemChanged(position); Toast.makeText(this, "更新成功", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, "更新失败", Toast.LENGTH_SHORT).show(); } }) .setNegativeButton("取消", null) .show(); }编辑对话框复用了新增对话框的思路,区别在于:编辑时要把原内容回显到 EditText 中,更新后调用notifyItemChanged(position)刷新当前条目。
这里要注意:noteList.indexOf(note)依赖 Note 对象重写的 equals 方法。由于我们并没有重写 equals,所以 indexOf 实际使用的是对象引用比较。在任务驱动式教学中,可以借此引出一个知识延伸——实际项目中,更稳妥的方式是直接用note.getId()遍历查找位置。
7.4 实现删除对话框
private void showDeleteDialog(Note note) { new AlertDialog.Builder(this) .setTitle("删除确认") .setMessage("确定要删除这条记事吗?") .setPositiveButton("删除", (dialog, which) -> { int rows = noteDao.delete(note.getId()); if (rows > 0) { int position = noteList.indexOf(note); noteList.remove(position); noteAdapter.notifyItemRemoved(position); Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, "删除失败", Toast.LENGTH_SHORT).show(); } }) .setNegativeButton("取消", null) .show(); }删除是很敏感的操作,尤其在生产环境中,任何删除动作都必须有二次确认。这里用 AlertDialog 弹出确认框,就是最典型的做法。
7.5 完整运行验证
运行 App,完整流程如下:
- 打开登录页,输入 admin / 123456 登录。
- 主页面展示数据库里的历史记事。
- 点击“新增记事”,输入内容,保存成功后列表顶部立即出现新数据。
- 单击任意记事,弹窗编辑内容,确认后列表对应条目更新。
- 长按任意记事,弹窗确认删除,列表移除该条目。
- 杀掉 App 重新打开,数据仍然存在,因为已经持久化到 SQLite。
任务 5 完成后,整个迷你记事本从“界面展示”到“本地数据持久化”已经全部跑通。你已经拥有了一个具备增删改查能力的 Android 小程序。
8. 常见问题与排查思路
在任务驱动式教学过程中,学生最容易在以下几个节点卡住。整理成排查清单,方便对照:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 登录按钮点击无效 | 布局中 Button 被其他控件遮挡,或监听器未正常绑定 | 检查 id 是否一致,检查findViewById是否返回 null |
| 应用启动时弹出选择界面 | Manifest 中多个 Activity 声明了 LAUNCHER 入口 | 只保留一个入口 Activity 的 intent-filter |
| RecyclerView 列表不显示 | 忘记设置 LayoutManager | 调用rvNotes.setLayoutManager(new LinearLayoutManager(this)) |
| 列表数据更新后界面不变化 | 修改 List 后未调用 Adapter 刷新方法 | 使用notifyItemInserted或notifyDataSetChanged |
| 数据库查询返回空 | 数据库文件未创建成功,或表名/字段名不匹配 | 查看日志确认建表 SQL,核对 Cursor 字段名 |
| 删除后列表位置错乱 | 直接按 index 删除而未同步 List 与 Adapter | 先删除 List 数据,再notifyItemRemoved(position) |
| MainActivity 报空指针 | 登录跳转时未传 username 参数 | 检查intent.putExtra与getStringExtra的 key 是否一致 |
| 数据库升级后用户数据丢失 | onUpgrade 中直接 DROP TABLE | 生产项目中应做表结构迁移,而不是删表重建 |
如果出现其它问题,最有效的排查方式是看 Logcat。Android 应用崩溃时的异常堆栈通常会直接指出错在哪一行代码,沿着堆栈向上查,绝大多数问题都能定位。
9. 最佳实践与工程建议
9.1 包结构合理划分
随着任务推进,文件会越来越多。建议在com.example.notebook下建立分包:
activity:存放 LoginActivity、MainActivity adapter:存放 NoteAdapter dao:存放 NoteDao、DBHelper model:存放 Note任务驱动式教学中,很多学生习惯把所有类都写在同一个包下,短期内没问题,但项目一旦超过 10 个文件,维护成本就会剧增。从第一个项目开始就按职责分包,是成本最低的工程化习惯。
9.2 登录逻辑的安全边界
本文登录功能使用本地硬编码校验,只能用于教学演示。真实项目中要注意:
- 密码不允许明文保存,应加密存储,最好由服务端统一管理。
- 登录状态应使用 Token 机制,而不是反复传输账号密码。
- 客户端只负责展示和交互,权限校验必须放在服务端。
- App 中不要保存敏感信息到 SharedPreferences 明文区域。
如果你只学 Android 基础,可以先不深入 Token,但必须建立“客户端校验不安全”的意识。
9.3 SQLite 与 Room 的选型
本文使用 SQLiteOpenHelper 是出于教学目的,它逻辑透明、容易理解底层。但从工程实践看,Android 官方更推荐使用 Room,Room 在 SQLite 之上封装了编译期 SQL 检查和协程支持,能显著减少样板代码。
建议学习路径是:先把 SQLite 搞明白,再上手 Room,这样即使 Room 出了奇怪问题,你也知道底层发生了什么。
9.4 数据库操作线程问题
本文的数据库操作都在主线程完成,对于记事本这种小数据量场景没有问题。但真实项目中的数据库查询、网络请求都不能在主线程执行,否则会触发NetworkOnMainThreadException或导致界面卡顿。基础阶段建议先掌握 Handler、AsyncTask 的历史演进,再了解协程与线程池。
9.5 日志规范
生命周期观察里用了Log.d。实战项目中,日志要分级使用:
Log.v/Log.d:开发调试细节。Log.i:关键业务节点。Log.w/Log.e:警告和错误。
应用发布前,建议把 Debug 日志全部关闭,避免泄漏内部实现细节。
10. 总结与下一步学习建议
通过这 5 个任务,你已经完整走通了 Android 开发中最核心的一条主线:从 XML 布局搭建界面,到 Java 代码绑定控件与事件,再到 Intent 页面跳转、RecyclerView 数据列表展示,最后用 SQLite 完成数据持久化和增删改查。这条路线正是童长飞老师任务驱动式教学体系中反复强调的“最小可运行闭环”,比按目录从第一章背到最后一章要有效得多。
整个迷你记事本应用虽然简单,却覆盖了一款真实 App 的基础骨架:登录入口、主页容器、数据模型、数据适配层、本地存储层、交互反馈。如果你能不看笔记,独立把这个项目从头到尾再写一遍,而不是复制代码,说明你已经真正掌握了这些基础知识点。
下一步可以继续挑战这些方向:
- 使用 RecyclerView 多类型条目,让列表支持不同的布局样式。
- 接入 Room 数据库,替代手写 SQLiteOpenHelper。
- 使用 Fragment 重构主页,了解单 Activity 多 Fragment 的架构。
- 学习 ViewModel 和 LiveData,让界面在屏幕旋转时不丢失数据。
- 增加“搜索记事”功能,练习模糊查询和列表过滤。
如果本文对你有帮助,可以收藏备用,后续进阶案例会继续围绕任务驱动式思路展开。动手把代码敲一遍,遇到报错就对照上面的排查清单,祝你在 Android 开发的学习路上少走弯路。