
用Android Studio从零写一个笔记APP是我最推荐新手走的一条路。最近有不少人私信我安装包下好了但打不开、界面全英文看不懂、列表能显示但一编辑就崩溃……这些问题我早年全遇到过。这篇就按我实际操作的顺序完整过一遍从新建项目到打包APK的流程最后还会把我踩过的几个坑一并说出来。无论你是刚装好IDE还是已经能跑HelloWorld但不知道怎么接数据库都可以照着做。笔记App这个选题很讨巧功能明确核心逻辑又正好覆盖Activity、RecyclerView、SQLite、页面跳转、数据传递这些Android开发的基本功。做完之后你手里有一个能真正用的工具而不是又一个计算器Demo。更重要的是它可以不断扩展——我今天先给出一版完整可运行的骨架后面想加搜索、标签、云同步都顺着这条路走。1. 两个准备为什么是笔记App以及把Android Studio装到能用的状态1.1 为什么笔记类App是最合适的练手项目很多人第一个App喜欢做计算器或者番茄钟不是不行但计算器只有布局和点击事件番茄钟只在单个页面里转做完之后你依然不知道怎么组织一个正经的App。笔记App的复杂度刚刚好它天然需要这几个东西列表页展示所有笔记这是App最常见的首页形态新建和编辑点击条目跳转到另一个页面涉及Activity跳转和参数传递数据存储笔记要能存下来关掉App再打开还在必须引入数据库或者文件存储增删改查哪怕是最简版也要有新增、读取、修改、删除四个动作这套逻辑以后在任何项目里都会复用。所以笔记App不是“一个小工具”而是一整条App开发主线的缩略版。你把它完整写一遍再回头去看复杂项目会发现首页、详情页、数据库操作这些都不陌生。这也是我反复跟新人说别跳步的原因直接去抄一个商城项目光商品列表和购物车就够晕的很难分清每段代码是干什么用的。1.2 下载、安装、中文界面的那些坑先说安装。Android Studio从官网developer.android.com就能直接下载Windows版本是一个exe安装包大约1GB。下载的时候注意别进到第三方站点那些“高速下载”往往带捆绑软件。安装过程里有两个地方值得留意第一是组件选择建议把“Android Virtual Device”勾上也就是模拟器组件后面调试会用到。如果电脑空间紧张至少也保留“Android SDK”和“Android SDK Platform”。JBR是JetBrains Runtime你已经装了Java也不需要管Android Studio自己带着运行环境不用另外配JDK。第二是安装路径尽量别用带空格和中文的目录。我见过一些人装在C:\Program Files (x86)下面平时没问题但后面Gradle脚本偶尔会因为在路径里带空格而抽风。省事一点直接装到一个纯英文文件夹例如D:\Android\Android StudioSDK也统一放在同一个盘后续调整不容易乱。安装完第一次启动会很长时间停在“Downloading Components”这一步。它要下SDK Platform和构建工具在国内网络环境下确实可能慢。这里不需要动任何代理设置正确做法是在之后项目的settings.gradle里配置镜像仓库这个我放到编译报错那一节单独讲。界面想变中文的话打开Settings选Plugins搜索“Chinese Language Pack”装上对应插件重启就变成中文了。注意不是所有菜单都会被汉化命令行输出和部分报错还是英文这不影响使用。1.3 首次启动前的SDK和模拟器准备进入欢迎页后先别急着新建项目打开右侧的SDK Manager确认装好了一个Platform。现在新项目默认用Android 13或14你可以选API 34。每年Google都会出新的API版本平时跟着最新稳定版就行SDK Platform不需要装很多多装反而拖慢编译。模拟器方面SDK Manager里面切到SDK Tools勾选Android Emulator和Android SDK Platform-Tools然后到欢迎页的Device Manager创建一个虚拟设备。我一般选Pixel 6的镜像系统镜像选不带Google Play的官方x86_64镜像这种镜像对开发调试足够而且体积比带Play Store的小。如果你的电脑没有开启硬件虚拟化模拟器启动会非常慢甚至报错Windows需要在BIOS里开Intel VT-x或者AMD-V。第一次启动模拟器要几分钟这是正常的不是死机多等一会儿。2. 页面骨架从空白模板到能滑动的笔记列表2.1 新建项目时怎么选模板和语言为什么我推荐Kotlin打开Android Studio选择“New Project”。模板这里我建议选“Empty Views Activity”注意是Views Activity不是Compose。Compose是新的声明式UI框架确实很流行但对新手来说先弄懂Activity、布局XML、控件id这些传统View体系的底层概念再去学Compose会轻松很多。这就像先学会手动挡再开自动挡后面不会糊涂。语言我推荐Kotlin。虽然很多老教程还在用Java但现在Android Studio默认就是Kotlin官方文档也以Kotlin为主。Kotlin写起来更短空安全机制能在编译阶段挡掉很多崩溃。如果你已经有Java基础看Kotlin代码基本能猜懂不用太担心。项目名可以直接叫“NoteApp”包名com.example.noteapp。最低SDK选API 24就可以了覆盖95%以上还在用的手机不用为了追求新特性把最低版本抬太高。创建之后系统会生成MainActivity和activity_main.xml先在这里跑通页面骨架。2.2 数据类与临时数据先让列表转起来为什么强调先让列表转起来因为你不可能在数据库还没写好的时候就完成所有UI。这里我习惯先定义一个笔记数据类然后手动塞几条假数据让RecyclerView先跑起来后面再接数据库时只改数据来源界面不用动。新建一个Note.ktdata class Note( val id: Long, val title: String, val content: String, val updatedTime: Long System.currentTimeMillis() )这里的id给Long类型是为了后面适配SQLite的自增主键updatedTime用时间戳后面排序和展示修改时间都靠它。在MainActivity里先写一个临时列表private val notes mutableListOf( Note(1, 欢迎使用笔记, 这是第一条笔记), Note(2, 第二条, 试着点击和长按), Note(3, 待办清单, 买牛奶、写代码) )然后用RecyclerView展示。RecyclerView是Android里最常用的列表控件比老掉牙的ListView强得多它自带ViewHolder复用机制列表长也不会卡。activity_main.xml先改成这个布局?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical androidx.recyclerview.widget.RecyclerView android:idid/recyclerNotes android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / /LinearLayout现在先不急着放按钮等交互那一步再加。0dp加weight1的意思是让RecyclerView充满剩余空间这样后续在底部加其他控件不会挤压列表。2.3 RecyclerView、Adapter和布局列表滑动的三件套RecyclerView要显示数据必须有一个Adapter。很多人第一次在这卡住其实道理很简单Adapter就是你告诉RecyclerView“每个条目长什么样、绑定哪些数据”的中间人。首先建一个条目布局item_note.xml我用一个TextView显示标题一个小的TextView显示更新时间。?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding16dp TextView android:idid/textTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize18sp android:textStylebold / TextView android:idid/textTime android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize12sp android:textColor#888888 android:layout_marginTop4dp / /LinearLayout然后是Adapter。新建NoteAdapter.kt最核心的部分是onCreateViewHolder和onBindViewHolderclass NoteAdapter( private var notes: ListNote, private val onClick: (Note) - Unit, private val onLongClick: (Note) - Boolean ) : RecyclerView.AdapterNoteAdapter.NoteViewHolder() { inner class NoteViewHolder(view: View) : RecyclerView.ViewHolder(view) { val textTitle: TextView view.findViewById(R.id.textTitle) val textTime: TextView view.findViewById(R.id.textTime) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_note, parent, false) return NoteViewHolder(view) } override fun onBindViewHolder(holder: NoteViewHolder, position: Int) { val note notes[position] holder.textTitle.text note.title holder.textTime.text SimpleDateFormat(yyyy-MM-dd HH:mm, Locale.getDefault()) .format(Date(note.updatedTime)) holder.itemView.setOnClickListener { onClick(note) } holder.itemView.setOnLongClickListener { onLongClick(note) } } override fun getItemCount(): Int notes.size fun updateData(newNotes: ListNote) { notes newNotes notifyDataSetChanged() } }在MainActivity里把这些串起来recyclerNotes.layoutManager LinearLayoutManager(this) recyclerNotes.adapter NoteAdapter(notes, { note - // 先空着下一步做跳转 }, { note - // 先空着下一步做删除 true })到这里你运行一下应该能看到一个可以滑动的三条笔记列表。这个阶段做对后后面的存储和交互都只是在替换数据来源和处理点击事件不会动UI的骨架。3. 存储落地用SQLite把笔记真正存下来3.1 为什么不用文件存储而要选SQLite列表跑通之后下一步就是让数据持久化。很多新手第一时间会想到SharedPreferences但那个更适合存设置项和少量键值对比如用户昵称、开关状态。笔记这种东西数量不定、字段固定还需要按时间排序、按内容搜索明显是结构化数据。你可以把SQLite理解成一个轻量级Excel表。它不需要你另外装数据库服务Android系统内置直接用SQL语句操作就行。对笔记App来说一张表就够id、标题、内容、更新时间。以后想加标签、加分类也就是再加几个字段和表的事。还有一种方案是直接在本地写文件用JSON序列化整个列表。数据量小的时候挺方便但一旦要“查某条笔记”就得把整个文件读出来再过滤效率又低又啰嗦。SQLite有索引、有SQL查询这才是长期项目该走的路线。3.2 建表与数据库帮助类SQLiteOpenHelper的完整写法在Android里操作SQLite标准姿势是写一个SQLiteOpenHelper的子类。这个类的职责很简单帮你把“数据库在哪里创建”和“表结构长什么样”封装起来。新建NoteDbHelper.ktimport android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper class NoteDbHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { override fun onCreate(db: SQLiteDatabase) { db.execSQL( CREATE TABLE $TABLE_NAME ( $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, $COLUMN_TITLE TEXT NOT NULL, $COLUMN_CONTENT TEXT NOT NULL, $COLUMN_UPDATED_TIME INTEGER NOT NULL ) .trimIndent() ) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { db.execSQL(DROP TABLE IF EXISTS $TABLE_NAME) onCreate(db) } companion object { const val DATABASE_NAME note.db const val DATABASE_VERSION 1 const val TABLE_NAME notes const val COLUMN_ID id const val COLUMN_TITLE title const val COLUMN_CONTENT content const val COLUMN_UPDATED_TIME updated_time } }这里有几个经验点。DATABASE_VERSION非常关键以后你给表加字段或者改表结构必须把版本号加1然后在onUpgrade里做迁移。新手图省事直接DROP TABLE重建会清掉用户数据这在正式产品里是灾难。开发阶段这样做没关系但你要有这个意识。另外一个细节是时间字段。SQLite没有专门的datetime类型我直接用Long存毫秒时间戳排序和格式化都很方便也避免了时区问题。3.3 DAO封装增删改查让MainActivity保持干净数据库帮助类建好之后我建议再写一个独立的NoteDao专门负责对notes表的增删改查。这样做的好处是MainActivity不需要直接拼SQL语句逻辑更清晰换数据库实现的时候也只需要改DAO这一层。新建NoteDao.ktimport android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase class NoteDao(context: Context) { private val db: SQLiteDatabase NoteDbHelper(context).writableDatabase fun insertNote(title: String, content: String): Long { val values ContentValues().apply { put(NoteDbHelper.COLUMN_TITLE, title) put(NoteDbHelper.COLUMN_CONTENT, content) put(NoteDbHelper.COLUMN_UPDATED_TIME, System.currentTimeMillis()) } return db.insert(NoteDbHelper.TABLE_NAME, null, values) } fun getNotes(): ListNote { val list mutableListOfNote() val cursor db.query( NoteDbHelper.TABLE_NAME, null, null, null, null, null, ${NoteDbHelper.COLUMN_UPDATED_TIME} DESC ) cursor.use { while (it.moveToNext()) { val id it.getLong(it.getColumnIndexOrThrow(NoteDbHelper.COLUMN_ID)) val title it.getString(it.getColumnIndexOrThrow(NoteDbHelper.COLUMN_TITLE)) val content it.getString(it.getColumnIndexOrThrow(NoteDbHelper.COLUMN_CONTENT)) val time it.getLong(it.getColumnIndexOrThrow(NoteDbHelper.COLUMN_UPDATED_TIME)) list.add(Note(id, title, content, time)) } } return list } fun updateNote(id: Long, title: String, content: String) { val values ContentValues().apply { put(NoteDbHelper.COLUMN_TITLE, title) put(NoteDbHelper.COLUMN_CONTENT, content) put(NoteDbHelper.COLUMN_UPDATED_TIME, System.currentTimeMillis()) } db.update( NoteDbHelper.TABLE_NAME, values, ${NoteDbHelper.COLUMN_ID} ?, arrayOf(id.toString()) ) } fun deleteNote(id: Long) { db.delete( NoteDbHelper.TABLE_NAME, ${NoteDbHelper.COLUMN_ID} ?, arrayOf(id.toString()) ) } }这段代码已经很完整但注意两点。第一query方法最后一个参数是排序字符串我写的updated_time DESC可以让新笔记排在上面符合笔记App的一般习惯。第二cursor.use {}是Kotlin的扩展函数保证Cursor用完自动关闭老代码里经常会看到忘了close()导致泄漏的问题用这个写法可以避免。然后在MainActivity里初始化DAO把临时数据那部分替换掉private lateinit var noteDao: NoteDao private lateinit var adapter: NoteAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) noteDao NoteDao(this) adapter NoteAdapter(emptyList(), { note - // 跳转编辑页下一步做 }, { note - // 删除逻辑下一步做 true }) recyclerNotes.layoutManager LinearLayoutManager(this) recyclerNotes.adapter adapter } override fun onResume() { super.onResume() adapter.updateData(noteDao.getNotes()) }把数据读取放在onResume而不是onCreate是个小技巧。因为从编辑页保存返回时MainActivity会被重新走到onResume这样列表会自动刷新不需要画蛇添足去写额外刷新事件。4. 交互闭环新建、编辑、删除和及时刷新4.1 编辑页面的Activity跳转与数据回传现在页面已经有了数据但还不能新建和编辑。我习惯的做法是MainActivity右上角放一个“新建”按钮点击跳转到NoteEditActivity列表点击某条笔记时把笔记的id、标题、内容传过去编辑页保存后再把数据传回来。新建一个Activity右键包名 - New - Activity - Empty Views Activity名字叫NoteEditActivity。它的布局只需要一个EditText用来编辑标题一个EditText用来编辑内容还有一个“保存”按钮。?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical android:padding16dp EditText android:idid/editTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:hint标题 android:textSize20sp / EditText android:idid/editContent android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:gravitytop android:hint写点什么... android:textSize16sp / Button android:idid/buttonSave android:layout_widthmatch_parent android:layout_heightwrap_content android:text保存 / /LinearLayout在NoteEditActivity里接收数据class NoteEditActivity : AppCompatActivity() { private var noteId: Long -1L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_note_edit) noteId intent.getLongExtra(note_id, -1L) val title intent.getStringExtra(note_title) ?: val content intent.getStringExtra(note_content) ?: editTitle.setText(title) editContent.setText(content) buttonSave.setOnClickListener { val titleResult editTitle.text.toString().trim() val contentResult editContent.text.toString().trim() if (titleResult.isEmpty() contentResult.isEmpty()) { Toast.makeText(this, 内容为空不保存, Toast.LENGTH_SHORT).show() returnsetOnClickListener } val resultIntent Intent().apply { putExtra(note_id, noteId) putExtra(note_title, titleResult) putExtra(note_content, contentResult) } setResult(RESULT_OK, resultIntent) finish() } } }里面有一步容易被忽略空内容不保存。如果标题和内容都没填直接保存就会在数据库里插入一条空白笔记列表里全是空行体验很差。在MainActivity里用Activity Result API接收返回的数据。新版本推荐registerForActivityResult替代废弃的startActivityForResultprivate val editLauncher registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result - if (result.resultCode RESULT_OK) { val data result.data if (data ! null) { val id data.getLongExtra(note_id, -1L) val title data.getStringExtra(note_title) ?: val content data.getStringExtra(note_content) ?: if (id -1L) { noteDao.insertNote(title, content) } else { noteDao.updateNote(id, title, content) } adapter.updateData(noteDao.getNotes()) } } }新建按钮点击时启动编辑页buttonAdd.setOnClickListener { editLauncher.launch(Intent(this, NoteEditActivity::class.java)) }这个流程理清楚之后你会发现 “页面跳转 数据回传” 是整个App开发里最高频的套路很多业务页面都是这么串起来的。4.2 长按删除、确认对话框和空状态提示删除笔记我建议用长按这样误触概率低。在前面的NoteAdapter里已经预留了onLongClick回调现在把它接上。在MainActivity里adapter NoteAdapter(emptyList(), { note - val intent Intent(this, NoteEditActivity::class.java).apply { putExtra(note_id, note.id) putExtra(note_title, note.title) putExtra(note_content, note.content) } editLauncher.launch(intent) }, { note - AlertDialog.Builder(this) .setTitle(删除笔记) .setMessage(确定要删除“${note.title}”吗) .setPositiveButton(删除) { _, _ - noteDao.deleteNote(note.id) adapter.updateData(noteDao.getNotes()) } .setNegativeButton(取消, null) .show() true })长按事件返回true很重要表示这个长按事件已经被消费不会再触发普通的点击事件。删除之后如果列表空了页面会显示一个空白的RecyclerView容易让用户觉得App坏了。我在activity_main.xml里加一个TextView一开始默认隐藏TextView android:idid/textEmpty android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:gravitycenter android:text还没有笔记点击右上角 新建 android:textColor#999999 android:visibilitygone/然后在刷新列表的方法里做显隐切换private fun refreshList() { val notes noteDao.getNotes() adapter.updateData(notes) textEmpty.visibility if (notes.isEmpty()) View.VISIBLE else View.GONE }这样即使删到一条不剩页面也不会空得没头没尾。4.3 刷新列表的三种方式以及选择逻辑笔记App的列表刷新看起来很简单但这里面有讲究。最直接的是notifyDataSetChanged()我把这个写进了updateData。它的特点是简单粗暴整个列表全部重建数据量小的时候完全没问题。但数据量大了比如几百条笔记用户输入一个字符就全表刷新会有肉眼可见的卡顿也不利于过渡动画。第二种是notifyItemInserted()和notifyItemRemoved()只在某个位置插入或删除单个item效率高还能触发自带的动画效果。缺点是你必须准确算出位置多线程并发改数据时位置会变得很难维护。第三种是DiffUtil。它会在后台比较新旧两个列表的差异然后只更新变化的部分Google官方也推荐这种方式。但实现难度高一些要写一个继承DiffUtil.Callback的类新手在刚做完第一个App的时候我反而不建议立刻上先感受一下notifyDataSetChanged的局限再有动机学DiffUtil。对当前这个笔记App就用第一种就好。应用退到后台再回来onResume里调一次refreshList()数据不会多也不会少。记住一个原则列表刷新跟你操作数据库的动作保持同步别在Activity创建时读一次删除时又不刷新那样就是给自己埋bug。5. 编译、真机调试与APK从0到15.1 模拟器和真机怎么选ADB连接时最常见的失败开发阶段在模拟器上跑还是真机上跑这是个老问题。我给的结论很简单先把模拟器调好用来快速验证界面装到真机上测试传感器、摄像头、通知这些模拟器不给力的东西。笔记App用不到太多硬件特性模拟器完全够用但真机测试依然建议做因为模拟器和真机在键盘弹出、屏幕适配、后台进程回收上都有差异。真机调试的步骤手机进“设置” - “关于手机” - 连点版本号七次打开开发者模式再去开发者选项里打开“USB调试”用数据线连接电脑。之后手机会弹一个“允许USB调试吗”的窗口点允许。如果没弹大概率是USB连接方式还是“仅充电”从通知栏切到“文件传输”再试。连接成功后Android Studio顶部会显示设备名称。但偶尔你会看到设备显示为“unauthorized”意思是这台电脑没有被允许。解决方法是拔掉数据线重新插一次或者到开发者选项里点“撤销USB调试授权”再重新连接。还有一个容易踩的坑用了扩展坞或者劣质数据线导致只充电不传输数据这时候换一根短一点的线问题通常直接消失。模拟器和真机的性能差别我放在下表里项目模拟器真机启动速度慢冷启动可能要几分钟快界面刷新在大屏上调试方便与用户实际一致传感器/相机模拟支持有限完全真实崩溃日志可以直接看也能通过adb看推荐场景UI与逻辑开发发版前验证5.2 Gradle同步和编译报错新手最痛的一关新手第一次创建项目或者同步依赖库经常会遇到Gradle同步失败。最直观的错误信息是类似“Could not determine the dependencies of task :app:compileDebugJavaWithJavac”后面跟着一串“Could not resolve all task dependencies”。看到“resolve”这个词基本就是依赖下载不下来。这里我要重点说Gradle本身和Android插件的下载在国外服务器国内网络环境下经常超时。正确解法是给项目配置国内镜像仓库。打开根目录的settings.gradle把仓库地址改成阿里云镜像dependencyResolutionManagement { repositories { google() maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/central } maven { url https://maven.aliyun.com/repository/gradle-plugin } } }同时可以检查gradle/wrapper/gradle-wrapper.properties里的distributionUrl如果下载慢也可以手动用浏览器下载对应版本的gradle-x.x-all.zip放到本地~/.gradle/wrapper/dists目录里再重新Sync这样能省大量等待时间。Gradle同步报错还有一个常见原因是JDK版本不匹配。Android Studio自带JBR一般没问题但如果你在系统环境里装了OpenJDK 17有的老项目却要求11就会报“Unsupported Java”。解决办法是打开File - Project Structure - SDK Location把JDK指定为Android Studio自带的JBR或者直接把Gradle JDK设成当前版本保持一致。另一个高频问题是清理不干净。依赖明明改好了还是报错可以执行一次Build - Clean Project再Build - Rebuild Project。如果还不行关闭Android Studio删除项目里的.gradle文件夹重新打开让它重新下载。八成能解决。5.3 打包签名APK装到别人手机上的注意事项到了这一步你的笔记App已经能在自己手机上跑了下一步是想发给朋友试试。这就涉及打包签名。Android要求所有APK必须用证书签名。开发阶段Android Studio用的debug签名帮你偷偷处理了但发给别人或者上架应用市场必须用正式签名。操作路径Build - Generate Signed Bundle / APK选择APK点击Next。第一次需要创建一个新的keystore。这里要注意Key store path选一个单独目录保存最好跟项目目录分开别传Git仓库密码用你记得住但别人猜不到的忘记后密码没法找回Alias是签名别名写应用名拼音一类就行建议连证书有效期越长越好Google Play要求有效期到2033年以后所以直接选25年。生成时选择release类型两个签名版本V1和V2都勾上。V1兼容Android 7以下老系统V2更安全默认两个都勾就行。打包完成后把APK发给朋友对方安装时如果提示“未知来源”需要手动允许“安装未知应用”。你可以引导对方在系统设置里对浏览器或者聊天软件开启这个权限。另外正式包和debug包可以同时装在同一台手机上因为它们签名不同应用名也不同debug版显示的是包名后缀.debug。如果你以后要上架应用市场记住签名必须保持不变一旦用第二个keystore重新签名所有平台的版本更新都会不认账用户只能卸载重装。6. 如果还想继续笔记App的二阶段扩展思路6.1 我可以加什么功能搜索、标签、排序这篇做到这里已经是一个可以日常使用的完整笔记App。再做下去有几个方向非常自然而且能巩固已有的知识。搜索功能。不用引入任何搜索引擎数据库里用一条SQL就能搞定。在DAO里加一个方法fun searchNotes(keyword: String): ListNote { val list mutableListOfNote() val query SELECT * FROM ${NoteDbHelper.TABLE_NAME} WHERE ${NoteDbHelper.COLUMN_TITLE} LIKE ? OR ${NoteDbHelper.COLUMN_CONTENT} LIKE ? ORDER BY ${NoteDbHelper.COLUMN_UPDATED_TIME} DESC val cursor db.rawQuery(query, arrayOf(%$keyword%, %$keyword%)) ... }%是SQL里的通配符表示任意字符。这样用户输入“购物”能查到所有标题或内容里带“购物”的笔记。标签功能。可以给笔记表加一个tag字段建表时加一列或者建一张标签表再建一张笔记和标签的关联表这就是从单表到多表关系的跨越对进阶练习很有价值。排序功能也一样在query的排序参数里把updated_time DESC换成created_time DESC或者按标题字母排序都是顺手的事。6.2 我已经踩过的一些坑和后续建议最后说几个真实项目里容易翻车的地方大多数是我自己踩过的。一个是数据库读写的位置。我在这篇里直接在UI线程读写SQLite数据量小的时候没问题。但一旦笔记多了或者以后做全文搜索在主线程执行复杂查询界面会假死最后弹“ANR”对话框。Kotlin里可以用协程把数据库操作丢到Dispatchers.IO然后回到主线程刷新UI。这是你下一步一定要养成的习惯。另一个坑是时间显示格式。如果用SimpleDateFormat建议在Adapter里创建一次不要每次绑定item都new SimpleDateFormat那样滑动列表时会有微小卡顿。更推荐对时间格式化用java.time库在API 26以上的设备上直接用更简洁。还有一个小技巧让App退出时自动清空EditText的临时状态。在编辑页里如果用户写了一大段文字突然接到电话App被系统回收Activity重建后EditText内容本来能自动恢复但如果你在onSaveInstanceState里额外保存一份noteId处理不好反而会造成id重复。我现在的习惯是编辑页不自己做状态保存完全依赖系统对EditText的自动恢复id用onSaveInstanceState单独存一份避免进程被杀后id丢失。到这里这个笔记App已经从空项目变成了一个能新建、编辑、删除、持久化并且可以打包分享的完整应用。你可以继续加搜索、加标签也可以在这个基础上重构成ViewModel加Repository架构。做App其实就是把一个大目标拆成一个个小闭环每完成一个闭环下一次再遇到类似需求心里就有底很多。