很多人一提到“Android项目开发实战”,脑子里立刻浮现出电商、短视频、即时通讯这种大工程,然后打开 Android Studio 被 Gradle 同步卡住,三天后放弃。我带过几届新人,也见过不少自学的人绕远路,最后真正坚持下来的,几乎都是从“简单备忘录”这种小东西起步的。原因很朴素:备忘录的需求边界足够清晰,一个输入框、一个列表、一个数据库,但它把 Android 开发里最核心的几件事全串起来了——Activity 生命周期、列表复用、数据持久化、异步线程、文件读写、通知与权限,一样都不少。你把这个项目做透,再回头看 MVVM、Hilt、Compose 这些进阶内容,会有种“原来讲的是同一件事”的感觉。这篇文章写给三类人:会一点 Java 或 Kotlin 但没写过 Android 的初学者、写过 Demo 却没做过完整应用的在校生、以及想找个模板快速搭工具类 App 的独立开发者。我尽量把每一步“为什么这么做”讲明白,代码能直接抄,坑也提前给你标出来。
1. 选“备忘录”当练手项目,我是怎么想的
1.1 需求拆解:一个备忘录最少要有哪几件事
先把需求摊开,别急着敲代码。备忘录这种东西看着简单,真做起来功能会像滚雪球一样膨胀,所以第一件事是砍需求,定一个“第一版必须做完”的清单。
我给初学者的第一版清单通常是这样:新建笔记、编辑笔记、删除笔记、按修改时间倒序展示、空状态提示。这五条做完,你已经跑通了一个完整的“增删改查 + 列表渲染”闭环。第二版再加:搜索、置顶、颜色标签、字数统计、长按多选。第三版才考虑提醒通知、导出备份、深色模式、云同步(云同步我建议放到最后,甚至会劝你先别做,因为它会把网络、鉴权、冲突合并一并带进来,复杂度是前十项加起来的总和)。
为什么要这样分批?因为新手最常见的失败模式是“一边写一边加需求”,写着写着数据库字段改了七八次,最后自己都不知道表结构长什么样。定清单的另一个好处是能倒推技术选型。比如你要“按修改时间倒序”,那数据库里就必须有 updated_at 字段,而且要在每次保存时更新它;你要“搜索”,就决定了查询语句得支持 LIKE 或 FTS,而不是在内存里过滤整个列表。
还有一个隐形需求很多人第一天想不到:输入过程中的临时保存。用户写了一屏字,来了个电话,Activity 被系统回收,回来发现内容空了,这种体验足以让一个人卸载你的 App。所以“onSaveInstanceState 兜底”和“草稿自动保存”应该被算进第一版,而不是当成优化项。
1.2 技术选型:Room 还是裸 SQLite,RecyclerView 还是别的
选型这块我踩过坑,说几个结论,理由也一并给你。
数据持久化,我给的建议是直接上 Room。裸 SQLite 不是不能用,但你得自己写 SQLiteOpenHelper、自己拼 Cursor、自己处理列索引越界,写着写着就变成“SQL 字符串工程师”。Room 是 Google 官方在 SQLite 上包的 ORM,编译期校验 SQL 语句,你写错字段名直接编译不过,这对新手太友好了。代价是要理解注解处理器和一点协程,但这点学习成本比起手写 Cursor 的调试时间,划算得多。如果你甚至不想碰数据库,SharedPreferences 或 DataStore 也能存,但它们不适合存列表,笔记一多就卡,别走这条路。
列表控件,用 RecyclerView,不要用 ListView。ListView 是老东西了,Adapter 模式繁琐、没有默认的局部刷新、动画也弱。RecyclerView 配合 ListAdapter + DiffUtil,能自动算出哪几项变了,只刷新变化的那一行,滑起来很顺。热搜里经常能看到“android中协调布局+banner”这类词,本质上都是 RecyclerView 的变体用法,把这一个控件吃透,后面的 Banner、瀑布流、折叠卡片都只是换 LayoutManager 的事。
架构层面,第一版我建议最朴素的写法:Activity 直接持有 ViewModel,ViewModel 持有 Repository,Repository 持有 DAO。别一上来就上 Hilt 依赖注入,你得先体会“手动 new 对象很烦”这件事,才会理解 Hilt 解决了什么问题。这个顺序反过来学,很容易变成“背注解但不理解”。
语言选择上,如果你是新学,直接用 Kotlin。官方文档、示例、新库基本都是 Kotlin 优先,协程处理异步比 Java 的 Thread + Handler 干净太多。
1.3 工程结构:包怎么分才不别扭
新手常见的是所有文件堆在一个包下,二三十个类之后就没法看了。我习惯按“职责”分,不按“类型”分:
com.example.memo/ ├── data/ 数据层 │ ├── Note.kt 实体 │ ├── NoteDao.kt 查询接口 │ ├── AppDatabase.kt 数据库入口 │ └── NoteRepository.kt 仓库 ├── ui/ │ ├── list/ 列表页 │ ├── edit/ 编辑页 │ └── common/ 通用组件 ├── util/ 工具类 └── MemoApp.kt Application为什么不按 Activity/Fragment/Adapter 分?因为当你改一个功能时,改动通常集中在某一个业务里,按职责分包能让你一眼找到相关文件,而不是在三个包之间来回跳。包名尽量短而清晰,别搞com.example.myfirstandroidapplicationproject这种超长前缀,改起来烦。
有一点要提醒:Application 类和数据库实例必须做成单例。Room 的RoomDatabase.Builder每次调用都会新建一个连接池,如果你在每个 Activity 里都 build 一次,很快就会看到“数据库连接泄漏”的日志甚至崩溃。标准做法是放在 Application 里,或者用一个object持有。
2. 环境搭建与工程初始化:新手最容易卡住的地方
2.1 Android Studio 安装与 SDK 组件取舍
环境这块是劝退重灾区,我把关键点列一下。下载 Android Studio 走官网就行,安装时会有个 SDK 组件勾选页面,默认全勾会占好几个 G,其实第一版备忘录用不到 NDK、用不到系统镜像(除非你要跑模拟器)。我的建议是:SDK Platforms 勾一个你目标版本的系统(比如 Android 14/API 34),SDK Tools 里勾 Android SDK Build-Tools、Android SDK Platform-Tools、Android SDK Command-line Tools 三项就够了。模拟器如果电脑内存够(16G 以上),可以装一个;不够就老老实实真机调试,速度反而更快。
关于“android studio怎么设置中文”和“android studio汉化”这类热搜词,我说一下态度:初学阶段不建议汉化。原因很现实——你遇到报错去搜资料,看到的中文教程、官方文档、StackOverflow 答案里全是英文菜单名,汉化之后你反而对不上号。菜单就那么几个词,用一周就熟了。真要看不懂,配个翻译插件比整体汉化靠谱。
还有“android sdk 官网下载”“android sdk command line tools runs”这类需求,通常是因为有人想脱离 IDE 用命令行构建。备忘录这种项目前期没必要,等你要做 CI 自动打包时再研究 cmdline-tools 也不迟。
2.2 新建工程时的几个关键选项
点 New Project 之后,模板选Empty Views Activity(老版本叫 Empty Activity),别选 Compose 模板,也尽量别用带 Bottom Navigation 的那种复杂模板,它会塞一堆你用不上的代码,反而干扰理解。
接下来几个选项很关键:
| 选项 | 建议值 | 理由 |
|---|---|---|
| Language | Kotlin | 官方主推,协程好用 |
| Minimum SDK | API 24 或 API 26 | 覆盖率高,且能用大部分新 API |
| Build configuration language | Kotlin DSL(build.gradle.kts) | 新工程默认,未来趋势 |
| Package name | 简短小写,避免中文和数字开头 | 后期改名成本极高 |
minSdk 的选择我再展开说一下。选 API 21 覆盖率最高,但你会失去很多新 API,每次写新特性都要加版本判断,很烦。选 API 26(Android 8.0)能覆盖绝大多数在用设备,且 Java 8 的很多特性、通知渠道、后台限制模型都是从这个版本开始标准化的,学到的知识和现在的主流写法一致。我一般让学生直接选 26,省心。
包名一定要在新建时就定好,因为一旦发布上线,包名就是应用的唯一身份,改包名等于换一个 App。曾经见过有人做到一半觉得包名难看,手动全局替换,结果 FileProvider 的 authority、签名配置、深链全部失配,查了两天。
2.3 Gradle 依赖管理与镜像加速
新建完工程第一次同步,很多人会卡在“Downloading xxx”上不动,这不是你的错,是依赖仓库在境外。解决办法是配置国内镜像源,把仓库地址换成国内主流 maven 镜像,同步速度会从“泡杯咖啡回来还在转”变成几秒钟。
改的地方有两处:老工程在项目根目录的build.gradle,新工程在settings.gradle.kts的pluginManagement和dependencyResolutionManagement里。下面是 Kotlin DSL 的写法示例,把镜像地址加在 google() 之前:
dependencyResolutionManagement { repositories { maven { url = uri("https://maven.aliyun.com/repository/public") } maven { url = uri("https://maven.aliyun.com/repository/google") } google() mavenCentral() } }凡是搜到“android studio init.gradle”的,多半是想要全局生效的镜像配置——把 init 脚本放到~/.gradle/init.gradle下,所有工程都会自动走镜像,适合同时维护多个项目的人。
依赖方面,备忘录第一版大概需要这几样:
dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") ksp("androidx.room:room-compiler:2.6.1") }注意 Room 的编译器现在推荐用KSP而不是老的 kapt,编译更快。用 KSP 需要在 plugins 里加上com.google.devtools.ksp,版本号要和你的 Kotlin 版本对应,这一步版本对不上是最常见的报错来源,配错了会看到一堆 “Unresolved reference” 但报错信息完全不提版本问题。
注意:升级依赖时一次只升一个,改完同步一次、跑一次,不要一口气全升到最新,否则出问题你根本不知道是谁干的。
2.4 真机调试:adb 与无线连接
模拟器能跑,但真机体验更真实,尤其是软键盘弹出、通知、文件路径这些,模拟器和真机差异不小。真机调试要先开开发者选项:设置里连点“版本号”七次,然后打开 USB 调试,插上线,手机上会弹授权框,勾“始终允许”。
adb这个工具藏在 SDK 的 platform-tools 目录下,建议把它加进系统环境变量,之后命令行里能直接用:
adb devices # 看设备有没有连上 adb install app-debug.apk # 手动装包 adb logcat -v time *:E # 只看错误日志,排查崩溃热搜里“如何使用 android studio 无线连接调试 vivo 手机”问的人很多,思路是通用的:手机和电脑连同一个 Wi-Fi,Android 11 以上可以直接在开发者选项里开“无线调试”,拿到配对码后用adb pair配对,再adb connect ip:端口连接。Android 10 以下的老设备得先用 USB 执行adb tcpip 5555,拔线后再adb connect。实测下来无线调试偶尔会断,写代码时还是插线稳,无线适合演示和调试不方便插线的场景。
3. 数据层:Room 实体、DAO 与版本迁移
3.1 表结构设计:字段背后的取舍
表结构是整个项目的地基,改起来最痛,所以一开始就要想清楚。我的备忘录实体是这么设计的:
@Entity(tableName = "notes") data class Note( @PrimaryKey(autoGenerate = true) val id: Long = 0L, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "content") val content: String, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, @ColumnInfo(name = "pinned") val pinned: Boolean = false, @ColumnInfo(name = "color_tag") val colorTag: Int = 0, @ColumnInfo(name = "remind_at") val remindAt: Long? = null )逐个说取舍。id用自增 Long 而不是 UUID,因为自增主键在 SQLite 里是 rowid 的别名,索引效率最高,本地单机应用不需要全局唯一。createdAt和updatedAt都用毫秒时间戳(Long)而不是字符串,因为排序、比较、格式化都方便,展示时再用 SimpleDateFormat 转成人类可读的样子。有人图省事存 “2024-05-01 12:00” 这种字符串,结果排序时 “10月” 会排在 “2月” 前面,这种坑早点避开。
pinned用 Boolean,Room 会自动映射成 INTEGER 的 0/1。colorTag用 Int 存颜色索引而不是直接存颜色值,这样换主题时颜色能跟着变,不会写死。remindAt是可空的 Long,空表示没设提醒,这里必须用可空类型,否则没法表达“没有提醒”这个状态,用 0 当哨兵值迟早会出错。
提示:字段命名统一用下划线风格(
created_at),用@ColumnInfo显式指定。这样以后你换语言、换框架,数据库文件里的列名是稳定的,不会因为 Kotlin 属性改名而被动迁移。
3.2 DAO 与查询:模糊搜索、排序、分页
DAO 接口是整个数据层的门面,写得好的话上层几乎不用关心 SQL。先看核心查询:
@Dao interface NoteDao { @Query(""" SELECT * FROM notes WHERE (:keyword = '' OR title LIKE '%' || :keyword || '%' OR content LIKE '%' || :keyword || '%') ORDER BY pinned DESC, updated_at DESC """) fun observeAll(keyword: String): Flow<List<Note>> @Query("SELECT * FROM notes WHERE id = :id LIMIT 1") suspend fun findById(id: Long): Note? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(note: Note): Long @Delete suspend fun delete(note: Note) @Query("DELETE FROM notes WHERE id IN (:ids)") suspend fun deleteByIds(ids: List<Long>) }几个细节值得说。第一,返回值用Flow<List<Note>>而不是List<Note>,这样数据库一有变化,界面自动收到新数据,不用手动调 refresh,这是 Room 最爽的地方之一。第二,排序里pinned DESC在前、updated_at DESC在后,意思是置顶的排最上面,置顶组内部再按修改时间倒序,符合直觉。第三,搜索用LIKE '%' || :keyword || '%'拼接,||是 SQLite 的字符串连接符,这样写比在代码里拼"%$keyword%"更安全,能避免一部分注入风险。第四,批量删除用IN (:ids),Room 会自动展开成占位符列表,比循环单条删快得多。
如果以后笔记量到几千条,LIKE '%xxx%'会慢,因为前置通配符用不上索引。那时候可以上FTS(全文检索),Room 有@Fts4注解支持虚拟表,搜索速度能快一个量级。但笔记几百条以内,普通 LIKE 完全够,不要过早优化。
3.3 版本迁移:别让用户的数据丢在升级里
数据库迁移是新手最容易忽略、上线后最要命的东西。你在开发阶段改了实体字段,如果不写迁移,运行时会直接抛IllegalStateException: Room cannot verify the data integrity,App 一启动就崩。
开发阶段有个省事的办法,fallbackToDestructiveMigration(),意思是版本对不上就删库重建。但这个只能用在开发期,发布版本绝对不能用,它会清空用户所有笔记。发布版必须老老实实写 Migration:
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE notes ADD COLUMN remind_at INTEGER") } } Room.databaseBuilder(context, AppDatabase::class.java, "memo.db") .addMigrations(MIGRATION_1_2) .build()写迁移有几条铁律:新增列必须给默认值或允许为空,否则老数据那行没法填;不要删列也不要改列类型(SQLite 支持有限),实在要改就建新表、拷数据、删旧表、改名,四步走;每次改实体,版本号必须 +1 并补一条迁移,忘了加就会崩。我曾经为了图快,开发时一直用 destructive migration,结果发内测包之前才补迁移,忘了有一版改过类型,用户的内测数据全丢了,被骂得很惨。
4. UI 层:列表、编辑页与交互细节
4.1 RecyclerView 与 DiffUtil 的正确打开方式
列表页是整个 App 的门面,用 RecyclerView 配合 ListAdapter 是最省心的组合:
class NoteAdapter( private val onClick: (Note) -> Unit ) : ListAdapter<Note, NoteAdapter.VH>(Diff) { object Diff : DiffUtil.ItemCallback<Note>() { override fun areItemsTheSame(old: Note, new: Note) = old.id == new.id override fun areContentsTheSame(old: Note, new: Note) = old == new } class VH(val binding: ItemNoteBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val binding = ItemNoteBinding.inflate( LayoutInflater.from(parent.context), parent, false) return VH(binding) } override fun onBindViewHolder(holder: VH, position: Int) { val note = getItem(position) with(holder.binding) { title.text = note.title.ifBlank { "无标题" } preview.text = note.content.take(60) time.text = DateUtils.format(note.updatedAt) root.setOnClickListener { onClick(note) } } } }这里两个关键点。areItemsTheSame比的是 id,告诉 RecyclerView “这两个是不是同一条数据”;areContentsTheSame比的是整个对象(data class 自动实现的 equals),判断“内容有没有变”。两个方法写错了,会出现要么列表不刷新、要么整屏闪的诡异现象。
另一个容易忽略的是submitList()的异步特性。有人调完submitList立刻去取getItem(0),拿到的是旧数据,因为 DiffUtil 是在后台线程算的。正确的做法是在onBindViewHolder里取,或者用currentList而不是自己维护一份 List。
还有个高频需求是空状态。列表为空时要显示“还没有笔记,点右下角新建”,而不是一片空白。做法是监听 Flow,if (list.isEmpty()) emptyView.isVisible = true。这个细节很影响观感,别省。
4.2 编辑页与软键盘那些事
编辑页看着简单,实际上细节最多。首先,进入编辑页时如果是从列表点进来的已有笔记,标题栏应该显示“编辑”,如果是新建,显示“新建”,并且新建状态要自动弹出键盘、把光标放到正文。这些用windowSoftInputMode和requestFocus()配合能实现。
最大的坑是软键盘遮挡输入框。默认情况下键盘弹出来会把底部输入框盖住,你会看到用户打字看不见自己打的内容。解决办法是在 Manifest 里给编辑页的 Activity 加:
<activity android:name=".ui.edit.EditActivity" android:windowSoftInputMode="adjustResize" />adjustResize会让布局在键盘弹出时压缩,配合 ConstraintLayout 让输入区靠底部约束,就能自动顶上去。注意别同时用 adjustPan 和 adjustResize,两个一起写行为会互相打架。另外,如果你用了全屏、沉浸式状态栏,adjustResize可能失效,这时要用WindowInsetsCompat手动处理底部内边距,这是 Android 高版本适配的一个经典难点。
第三个细节是离开时自动保存。我的做法有三层:一是onPause时存草稿;二是输入停止 800ms 后自动保存(用 Handler 或协程的 debounce);三是点返回键时如果内容有变化,直接保存不弹框(备忘录又不是社交软件,不需要“是否放弃编辑”这种打断)。这三层叠起来,基本不会丢数据。
4.3 滑动删除、多选与撤销
删除操作的手感,直接决定用户觉得这个 App “专业”还是“玩具”。我的推荐组合是:左滑显示删除背景 + 松手删除 + Snackbar 提供撤销。
滑动用ItemTouchHelper实现,它负责手势识别,你只管在回调里执行业务逻辑:
val callback = object : ItemTouchHelper.SimpleCallback( 0, ItemTouchHelper.LEFT ) { override fun onMove(rv: RecyclerView, vh: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder) = false override fun onSwiped(vh: RecyclerView.ViewHolder, direction: Int) { val note = adapter.currentList[vh.bindingAdapterPosition] viewModel.delete(note) // 先乐观删除 Snackbar.make(binding.root, "已删除", Snackbar.LENGTH_LONG) .setAction("撤销") { viewModel.restore(note) } .show() } }这里有个设计决策要解释:是“先删数据库再提示”,还是“先提示再删”?我选先乐观删除,界面立刻响应,用户体验顺滑;同时把 note 对象留在内存里,点撤销就重新插回数据库。这样做的代价是撤销有极短的时间窗口内数据其实不在库里,如果 App 在那一瞬间被杀,数据就真没了。对备忘录这种场景可以接受(用户主动删除的,本来就要删),但如果是草稿类应用就要慎重。
多选模式我用长按触发,进入后每个 item 左侧出现 CheckBox,顶部标题栏变成“已选 N 项”。实现上用 Adapter 里维护一个selectedIds: MutableSet<Long>,每次切换notifyItemChanged刷新选中态。注意bindingAdapterPosition和layoutPosition的区别:滑动删除的回调里一定要用前者,因为删除过程中位置会变,用后者会取到错的数据——这个 bug 我调了整整一个下午。
4.4 深色模式适配
深色模式现在基本是标配,Android 10 之后系统级支持,用户切了主题你的 App 也跟着变,好感度直接拉满。适配的核心是颜色全部走资源引用,不要在代码里写死:
<!-- values/colors.xml --> <color name="surface">#FFFFFF</color> <color name="on_surface">#1A1A1A</color> <!-- values-night/colors.xml --> <color name="surface">#121212</color> <color name="on_surface">#E6E6E6</color>然后布局里统一写android:background="?attr/colorSurface"或引用自定义 color,系统会自动根据当前模式挑对应文件。要检查有没有漏网之鱼,最简单的办法是在开发者选项里开“强制深色”,然后在 App 里边走边看,哪里出现白底黑字配深色背景,那里就是漏了硬编码。
主题继承我建议从Theme.Material3.DayNight.NoActionBar起步,Material3 自带一套语义化颜色(primary、surface、onSurface),深色模式下会自动算对比度,比自己配颜色省事。别用Theme.MaterialComponents.Light这种写死亮色的主题,那样深色模式根本不会生效。
5. 功能增强:搜索、提醒、导出与签名
5.1 搜索过滤与输入防抖
搜索框用SearchView或者 Material 的TextInputLayout都行,重点是别每敲一个字就查一次库。做法是用协程的 debounce:
searchInput.textChanges() .debounce(300L) .distinctUntilChanged() .flatMapLatest { kw -> viewModel.search(kw.toString()) } .collect { list -> adapter.submitList(list) }debounce(300)的意思是 300 毫秒内没有新输入才真正执行,用户快速打字时只会触发最后一次,数据库压力小很多。flatMapLatest保证新查询来了就取消上一个,避免旧结果后到覆盖新结果。这两个操作符配合起来,是搜索体验的关键,我强烈建议一开始就用上,别等卡了再回来改。
搜索还有个体验细节:高亮关键字。在 title 和 content 里把匹配的部分用ForegroundColorSpan标出来,用户一眼就知道为什么这条被搜出来了。实现上在onBindViewHolder里做,注意先设文本再设 Span,顺序反了会失效。
5.2 提醒通知:AlarmManager 与 WorkManager 怎么选
提醒功能是备忘录从“记事本”升级成“助手”的分水岭,但也是最容易翻车的模块,因为涉及后台执行和通知权限。先看选型:
| 方案 | 适用场景 | 精度 | 说明 |
|---|---|---|---|
| AlarmManager | 精确到分钟的用户提醒 | 高 | 可用 setExactAndAllowWhileIdle |
| WorkManager | 延迟、约束性任务 | 低 | 系统调度,可能延后 |
| 前台服务 | 长时间运行 | 高 | 有常驻通知,体验差 |
给笔记设“明天早上 9 点提醒我”,我用AlarmManager,因为用户预期是准点响。用setExactAndAllowWhileIdle能穿过 Doze 模式(系统省电状态),但注意从 Android 12 开始精确闹钟需要声明SCHEDULE_EXACT_ALARM权限,且用户可能拒绝,拒绝后要降级成setAndAllowWhileIdle这种不精确的方式。
通知本身从 Android 8.0 起必须走通知渠道(NotificationChannel),不建渠道的通知根本不会显示。渠道要在 Application 启动时创建,importance选 HIGH 才会有横幅和声音。Android 13 之后还要动态申请POST_NOTIFICATIONS权限,不申请就发不出去,而且这个权限被拒后不能反复弹,得引导用户去设置页手动开。
5.3 导出备份:SAF 与 FileProvider
“我的笔记会不会丢”是用户最深的焦虑,加个导出功能能极大提升信任度。导出的正确姿势是用SAF(Storage Access Framework),让用户自己选存到哪里,你完全不需要申请存储权限:
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "text/plain" putExtra(Intent.EXTRA_TITLE, "memo_backup_${System.currentTimeMillis()}.txt") } launcher.launch(intent)用户选完之后你拿到的是一个content://开头的 URI,直接contentResolver.openOutputStream(uri)往里写就行。这套机制的好处是权限由系统托管,你拿到的只是一次性的授权,用完即失效,安全又省事。同理,导入时用ACTION_OPEN_DOCUMENT拿 URI 读取。热搜里那些content://路径根本不用你手动解析,交给 ContentResolver 就好,自己拼路径在不同厂商的系统上几乎必挂。
如果你要把文件分享给别的 App(比如分享一条笔记到聊天软件),那就得配FileProvider:在 Manifest 里声明 provider,写一个file_paths.xml指定可分享的目录,然后用FileProvider.getUriForFile()生成 URI 并加FLAG_GRANT_READ_URI_PERMISSION。这里最常见的报错是FileUriExposedException,原因就是直接传了file://的路径,Android 7.0 之后不允许了。
5.4 签名与 SHA1:上线前必做
调试阶段用的都是 debug 签名,一旦要发布或者接入某些需要校验签名的第三方能力,就必须配 release 签名。生成 keystore 用 Android Studio 的 Build > Generate Signed Bundle/APK 向导最省事,也可以命令行:
keytool -genkeypair -v -keystore memo.jks -keyalg RSA -keysize 2048 -validity 10000 -alias memo拿到 keystore 后,在 build.gradle.kts 里配 signingConfigs,密码不要硬编码提交到仓库,放在local.properties或环境变量里,用keystoreProperties.getProperty("storePassword")读。我见过有人把密码直接写进 gradle 然后推到公开仓库,等于把应用的控制权送人了。
获取 SHA1 是另一个高频需求(热搜里“android 应用签名 sha1 值”),Gradle 任务里自带:
./gradlew signingReport输出里会列出 debug 和 release 两种签名的 MD5、SHA1、SHA256。注意debug 和 release 的 SHA1 是不同的,很多人接第三方时用 debug 的 SHA1 去配,结果 release 包一装就报签名校验失败。另外,如果你的应用开了 Play 应用签名,那么线上包的签名和本地上传的 keystore 又不一致,要去后台取真正的签名指纹,这个坑值得提前记住。
6. 常见问题排查实录
6.1 崩溃与 ANR:logcat 到底怎么看
App 一闪退就打退堂鼓,是最可惜的。其实 90% 的崩溃看一眼日志就能定位。打开 Logcat 面板,过滤级别选 Error,找FATAL EXCEPTION那一行,往下看异常类型和第一行业务代码的堆栈:
adb logcat -v time *:E | grep -A 30 "FATAL EXCEPTION"常见的几类:NullPointerException多半是 lateinit 或可空类型没判空;IllegalStateException: Fragment not attached是异步回调回来时页面已经销毁;android.database.sqlite.SQLiteConstraintException是主键冲突;Resources$NotFoundException通常是布局里引用了不存在的资源或维度单位写错。
ANR(应用无响应)更隐蔽,原因是主线程被卡住超过 5 秒。最常见的是在主线程查数据库或做大数据量遍历。记住一条铁律:所有 IO 和数据库操作都放协程的Dispatchers.IO里:
viewModelScope.launch { val notes = withContext(Dispatchers.IO) { dao.getAll() } adapter.submitList(notes) }如果 ANR 已经发生,拉取 traces 文件分析:
adb shell cat /data/anr/traces.txt看主线程的堆栈,停在哪个方法上,问题就在哪。
6.2 列表数据错乱与不刷新
RecyclerView 的数据错乱是新手绕不开的坎,我总结了一个速查表:
| 现象 | 常见原因 | 解决 |
|---|---|---|
| 滑动后内容串行 | ViewHolder 复用没重置状态 | onBindViewHolder 里重设所有可变字段 |
| 点了没反应 | 用了 layoutPosition 取错数据 | 改用 bindingAdapterPosition |
| 改了数据不刷新 | 传了同一个 List 实例 | 传新对象或走 DiffUtil |
| 列表跳动 | submitList 时机在滚动中 | 先停滚动或改用 setHasStableIds |
| 图片错位 | 没有做图片加载的 tag 校验 | 用 Glide/Picasso 自带机制 |
第一行特别典型:Item 里有“置顶小图标”“选中勾选框”这类可选元素,你只在满足条件时设置isVisible = true,但没写else分支,复用到另一个不带图标的 item 上,图标还在。这类 bug 的修复方式很机械——onBindViewHolder 里每一个可能变化的 View 都要显式赋值,不留“默认状态”的幻想。
6.3 release 包与混淆的坑
debug 跑得好好的,打了 release 包就崩,八成是混淆(R8)干的好事。R8 会把没被“看见”引用的类删掉或改名,反射、序列化、Room 生成的实现类、数据类的字段名都可能中招。解决办法是加 keep 规则:
-keep class com.example.memo.data.** { *; } -keepclassmembers class * { @androidx.room.* <methods>; }不过现在 Room 和大部分 Jetpack 库都会自带 consumer rules,真正需要你手写的部分不多。我的建议是第一版直接关闭 minifyEnabled 发布,等应用稳定、体积真的成问题时再开混淆并逐个测试。很多人为了省几百 KB 把功能搞崩,得不偿失。真要开,就一定要测全流程:新建、编辑、删除、搜索、导入导出、提醒,一个都不能少。
还有一个 release 专属坑是签名不一致导致无法覆盖安装。手机上装过 debug 包,再装 release 包会报 “App not installed”,得先卸载旧包。测试同事发来“装不上”的截图时,先问这个。
我自己做完这个备忘录之后最大的体会是,Android 项目开发的门槛从来不在于某个 API 有多难,而在于一堆“没人明说”的小规矩:主线程不能碰数据库、Adapter 里每个可变状态都得显式重置、发布前一定要补迁移、密码不能进仓库。这些东西教程里往往一笔带过,但真正决定你的应用是“能跑”还是“能用”。如果你也在做类似的小项目,我的建议是把它真正装到自己手机上用一周,每天记录哪里别扭,那份清单比任何教程都值钱。