简介:这是一份面向Android学习者的Kotlin应用开发源代码包,属于教学参考资料。压缩包内共65个文件,由40个XML界面布局文件与25个Kotlin源文件组成,整体大小61KB,麻雀虽小五脏俱全。源码按章节组织,涵盖线性布局、网格布局、相对布局示例以及设备管理、记事本、播放器等典型场景,重点演示了Kotlin的空安全、类型推断、扩展函数、协程以及Jetpack组件(Room、LiveData、ViewModel)在实际项目中的用法。已有269人学习下载。通过对照XML与Kotlin代码,可以清楚理解界面与逻辑的分离方式,并学习如何用更简洁的语法实现Activity、Fragment,以及如何借助协程处理异步任务。对于正在学习Kotlin或准备从Java迁移的入门及中级开发者,这是一份可直接运行调试的实用参考,既能巩固基础语法,也能掌握现代Android开发的主流工具链,适合课程设计、自学或项目启动前的快速参考。
1. 一套随书Kotlin源码:覆盖布局、持久化、Service与播放器的完整链路
有人问“用Kotlin做Android应用开发,想找一份能一口气看完的实战源码该看什么”,我一般会推荐这种随正式教材走的教学资料。这个压缩包按章节拆好了一整套案例:ch2到ch4是三种布局的XML与控制代码,ch5是人员管理,ch2到ch7反复演进设备管理模块,ch8与ch9是两版记事本,ch10是播放器与通知栏控制。它不是一堆互不相关的零碎Demo,而是同一个业务在多个章节逐步升级的完整骨架。
正从Java切Kotlin的新手,可以拿它对照两种语言的写法差异;已经写过几年业务的老手,也能用半小时把SQLite、Service、通知渠道这些经典细节整体过一遍,捡回一些被Jetpack封装藏起来的底层认知。
2. 布局源码拆解:三种XML与Kotlin动态布局的取舍
2.1 三种布局的源码差异:看一眼就记住适用场景
ch4目录下有三个布局文件:relatvielayout.xml、gridlayout.xml、linearlayout.xml。先吐槽文件名:relatvielayout是笔误,少了一个i,正确写法是RelativeLayout。这种拼写错误在随书源码里很常见,不影响编译,但搜索代码时要注意文件名和类名的拼写不一致,容易搜不到。
linearlayout.xml是线性布局,子控件按水平或垂直方向依次排列。源码里典型的结构是外层垂直LinearLayout套若干水平LinearLayout,实现“表单”效果:姓名一行、年龄一行,每行内部是一个TextView标签加一个EditText输入框。优点是结构直白,缺点是条目越多嵌套层级越深,渲染阶段测量耗时跟着涨。
relativelayout.xml是相对布局,子控件通过layout_above、layout_below、layout_toRightOf这类属性互相锚定。源码里用它放底部的“保存/删除”操作区,按钮锚定在另一个按钮右侧,整体再锚定父容器底部。层级通常能压到一至两层,但约束多时阅读顺序不直观,改一个控件位置,关联它的控件全要跟着调。
gridlayout.xml是网格布局,源码里展示设备信息表格,两列三行的结构。直接设置columnCount与rowCount,控件通过layout_column和layout_row定位,不用多层嵌套,也不用像旧版TableLayout那样写冗余标签。
三个文件放在同一章,暗示同一界面可以用不同方案实现。实践中的选型逻辑是:表单类密集列表用LinearLayout,固定顶部底部区域用RelativeLayout,规整网格信息用GridLayout。现在的项目我一般优先用ConstraintLayout,它能把前三种的大部分场景压进单层布局,2.3节我会拿实际例子演示。
提示:教学源码里的布局设计偏演示,会刻意选用每种布局最典型的写法,不一定是最优实践,学习时注意区分。
2.2 把XML翻译成Kotlin动态布局:Anko的典型写法
ch2和ch3的主Activity里,部分控件是在代码里动态创建的,而不是全部写在XML中。这是早期Kotlin教程配合Anko库的常见写法。Anko提供了一套DSL,让开发者用Kotlin直接描述布局结构,省掉XML文件。典型的代码长这样:
// Anko DSL 动态创建布局 class PersonActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) verticalLayout { padding = dp(16) val nameInput = editText { hint = "请输入姓名" textSize = 16f } val ageInput = editText { hint = "请输入年龄" inputType = InputType.TYPE_CLASS_NUMBER } button("保存") { setOnClickListener { savePerson(nameInput.text.toString(), ageInput.text.toString()) } } } } }逻辑说明:verticalLayout对应垂直LinearLayout,editText内部通过hint和inputType设置占位与输入类型,button的文本通过构造参数传入,点击监听直接写在同一个花括号内。相比XML加findViewById,这种写法把布局与逻辑的距离拉得很近。dp(16)是Anko的尺寸扩展,等价于在XML里写16dp,自动完成像素换算。
但这里有一条血泪教训:Anko在2019年前后停止维护,官方早已不把它列入现代Android开发的推荐栈。照搬这份源码里的Anko依赖进新工程,大概率卡在Gradle解析阶段。正确的做法是把它当作“Kotlin能简化布局编写”的历史参考,新项目优先使用XML加ViewBinding或Compose。
2.3 布局的重构练习:从三层嵌套改成单层约束
读这套源码最值得做的练习,是挑ch4里嵌套最深的LinearLayout页面,用ConstraintLayout重写。先看原始的嵌套结构:
<!-- 原始三层嵌套 --> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="姓名" /> <EditText android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" /> </LinearLayout> </LinearLayout>改用ConstraintLayout后,目标是把“标签与输入框在同一行、输入框填满右侧剩余空间”的约束直接写在一个根节点上:
<ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/label_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="姓名" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <EditText android:id="@+id/input_name" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="12dp" app:layout_constraintStart_toEndOf="@id/label_name" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/label_name" app:layout_constraintBottom_toBottomOf="@id/label_name" /> </ConstraintLayout>关键属性:input_name宽度0dp,配合Start到label_name的End、End到父容器边缘、Top与Bottom都锚定在label_name上,实现“高度随标签居中,宽度自动撑满剩余空间”。这是ConstraintLayout最高频的写法,渲染时测量次数比嵌套两层LinearLayout少一次,调位置时也只需要改约束线。
在旧设备上,布局嵌套超过三层,列表滚动就可能掉帧。如果你读这套源码时发现某个页面滚动卡顿,先查根布局的嵌套深度,这比调业务代码省力得多。把嵌套降到三层以内,是布局性能优化的第一条经验。
3. 数据持久化:从people到devices的四次演进
3.1 同一套数据模型,多章节反复重写
压缩包里的数据模块分布很有规律:ch5是people,ch2到ch7是devices,ch8与ch9都是notes。people对应“人员信息管理”,devices对应“设备管理”,notes对应“记事本”。这种章节排布是教材刻意为之——同一业务在不同章节用不同数据方案重写,让你直观看到技术选型的差异。
这很像真实项目的演进路线:第一版用SQLiteOpenHelper直接操作SQL,第二版引入Room,第三版在表结构上加了ContentProvider。ch8的notes和ch9的notes对比着看,界面基本相同,数据访问代码差了一个时代:前者是回调式Cursor读取,后者是LiveData观察。把两个章节并排对比,就能理解“数据驱动UI刷新”的Jetpack方案到底先进在哪里。
3.2 SQLiteOpenHelper经典写法:看懂Cursor生命周期
老版本代码里的数据库操作,清一色是SQLiteOpenHelper加原生SQL。ch5的people模块,建表代码通常长这样:
class PeopleDbHelper(context: Context) : SQLiteOpenHelper(context, "people.db", null, 1) { override fun onCreate(db: SQLiteDatabase) { db.execSQL( """ CREATE TABLE people ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER ) """ ) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { db.execSQL("DROP TABLE IF EXISTS people") onCreate(db) } fun insertPerson(name: String, age: Int) { val values = ContentValues().apply { put("name", name) put("age", age) } writableDatabase.insert("people", null, values) } }逻辑说明:onCreate在数据库文件首次创建时执行,负责建表;onUpgrade在版本号升高时执行,这段代码用的是粗鲁重建——直接DROP再CREATE。数据写入用ContentValues包装字段名与值,insert方法返回新行的行号,失败返回-1。
这里有一个经典坑:onUpgrade直接DROP表会把用户已有数据清光。教学演示无所谓,线上应用这么写会被骂。正确的升级方式是ALTER TABLE增量修改或执行迁移SQL,第5章我会展开说。
查询部分的代码,老写法绕不开Cursor循环:
fun queryAllPeople(): List<Person> { val list = mutableListOf<Person>() val cursor: Cursor? = readableDatabase.query( "people", null, null, null, null, null, null ) cursor?.use { while (it.moveToNext()) { val id = it.getLong(it.getColumnIndexOrThrow("_id")) val name = it.getString(it.getColumnIndexOrThrow("name")) val age = it.getInt(it.getColumnIndexOrThrow("age")) list.add(Person(id, name, age)) } } return list }cursor?.use { } 是Kotlin的作用域函数,代码块结束后自动close,防止Cursor泄漏。读取列值用getColumnIndexOrThrow拿索引再取值,比硬编码数字安全,表结构变了能第一时间暴露问题。这也是从这套源码里能直接学到的Kotlin习惯:use、let、apply贯穿整套代码。
3.3 用Room重写数据层:三步完成现代化
拿这套源码练手,我建议把people模块的数据层用Room重写一遍。Room是Jetpack的SQLite抽象层,编译期验证SQL,返回LiveData自动刷新界面。重构分三步:定义实体、定义DAO、定义数据库。
@Entity(tableName = "people") data class Person( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "age") val age: Int ) @Dao interface PersonDao { @Query("SELECT * FROM people ORDER BY id DESC") fun observeAll(): LiveData<List<Person>> @Insert suspend fun insert(person: Person) } @Database( entities = [Person::class], version = 1, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun personDao(): PersonDao }分段说明:Person实体直接对应people表,注解标明主键与列名;PersonDao用注解声明SQL与函数绑定,observeAll把查询结果包进LiveData,insert被标记为挂起函数,只能在协程里调用;AppDatabase是数据库入口,抽象方法返回DAO实例,Room在编译期自动生成实现。
与前面的SQLiteOpenHelper对比,Room把“写SQL、拿Cursor、手动关连接”压缩成“定义接口、调用方法”,而且天然配合Kotlin协程,DAO方法直接写成suspend,不用再手动切换线程。这套源码的ch9如果已经用了Room,学它;如果还是老写法,把ch8改造成ch9,这个动手过程比十遍语法书都管用。
4. 播放器与通知:MediaPlayer状态机、前台服务与通知渠道的配合
4.1 MediaPlayer状态机是播放器的核心
ch10的player模块是音频播放器示例。MediaPlayer用起来简单,new实例、setDataSource、prepare、start,但内部状态机非常严格:空闲、初始化、准备中、就绪、播放中、暂停、播放结束、错误、释放。任何一个方法在错误状态下被调用,都会抛IllegalStateException。
class PlayerManager { private var mediaPlayer: MediaPlayer? = null fun play(url: String) { stop() mediaPlayer = MediaPlayer().apply { setDataSource(url) setOnPreparedListener { it.start() } setOnErrorListener { _, _, _ -> release() true } } mediaPlayer?.prepareAsync() } fun pause() { if (mediaPlayer?.isPlaying == true) { mediaPlayer?.pause() } } fun stop() { mediaPlayer?.release() mediaPlayer = null } }逻辑说明:play的顺序是创建实例、设置数据源、发起异步准备,准备完成回调里才真正start。prepareAsync必须用于网络资源,不能在主线程直接prepare。setOnErrorListener返回true表示错误已处理,避免异常继续上抛。pause先判断是否正在播放再暂停,状态不对时调用pause会直接崩溃。stop统一release并置空,确保下次播放重建实例。
写播放器最容易翻车的点,是连续快速点击播放:第一次还没准备好,第二次又触发一遍play,旧实例没有释放,内存与资源双重泄漏。我自己的习惯是每次play前强制走stop,就是代码注释体现的顺序,这个防重入手法在多媒体场景尤其重要。
4.2 前台服务与通知渠道:Android 8之后的重构重点
player模块一般配套Service保持后台播放。旧版Android随意startService就能常驻,Android 8.0后的后台执行限制会让普通Service在退到后台几分钟内被系统杀掉。音频播放必须改成前台服务,并通过通知栏常驻一条“正在播放”的通知。通知渠道的创建代码:
fun createPlaybackChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = context.getSystemService(NotificationManager::class.java) val channel = NotificationChannel( "playback", "播放控制", NotificationManager.IMPORTANCE_LOW ).apply { description = "播放器常驻通知" setShowBadge(false) } manager.createNotificationChannel(channel) } }参数说明:第一个参数是渠道ID,必须与之后构建通知时指定的channelId完全一致;第二个参数是用户可见的渠道名称;IMPORTANCE_LOW表示低重要程度,不发声、不震动,适合音乐播放这类常驻通知;setShowBadge(false)关闭桌面角标。只有Android 8.0及以上需要渠道,所以包一层版本判断。
随后构建通知时,通过setChannelId("playback")绑定这个渠道,再用startForeground(id, notification)把Service置为前台。如果忘了创建渠道,通知会直接不显示,而且Logcat里没有任何报错,排查时很迷惑。优先去系统设置里看App的通知分类是否存在,不存在就是渠道没建。
4.3 音频焦点的处理:源码里最容易缺失的一环
教学源码的播放器普遍缺少“音频焦点”处理。音频焦点是Android协调多个App声音的机制:播放器后台放歌,来了导航语音或另一首歌,系统会发出焦点变化通知。处理方式是请求焦点、监听变化、暂停或恢复播放:
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run { setOnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> playerManager.pause() AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> playerManager.pause() AudioManager.AUDIOFOCUS_GAIN -> playerManager.start() } } build() } audioManager.requestAudioFocus(focusRequest)说明:AUDIOFOCUS_GAIN表示长时间获取焦点,适合播放器;LOSS表示焦点被永久夺走,对应暂停并释放资源;LOSS_TRANSIENT表示短暂失去焦点,比如语音助手播报,暂停即可;重新获得焦点时恢复播放。没有这套处理的播放器,在真实手机上会被系统静音,但界面还显示播放中,是用户感知最明显的体验缺陷。ch10源码如果没有这一块,建议自己补上,这是模拟线上播放器最接近的练习。
5. 避坑手记:读这套教学源码最常踩的五个坑
5.1 Anko依赖拉不下来,工程无法编译
现象:ch2、ch3里的Activity引用了Anko的包,Gradle同步时直接报Could not find,整个工程卡在依赖解析阶段,一步都跑不动。
原因:Anko已停止维护,依赖坐标停留在旧版本,与当前构建工具链冲突。
解决:不保留Anko依赖。把动态布局代码翻译成XML或ViewBinding,以界面效果为准重写,用竖直LinearLayout加水平条目的结构替代原来的DSL写法。
5.2 SQLite升级把用户数据清空
现象:ch8的notes表升级增加字段后,老用户更新App,打开笔记发现历史数据全部消失。
原因:教学代码的onUpgrade直接执行DROP TABLE再CREATE,教材图省事,线上这么写等于删库。
解决:改用Room迁移,用Migration保留旧数据:
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( "ALTER TABLE notes ADD COLUMN update_time INTEGER DEFAULT 0" ) } }这段迁移将notes表从版本1升到版本2,只加一列,不动原有数据。Room打开数据库时自动检查版本并执行对应Migration,比手写SQLiteOpenHelper判断version更不容易漏。
5.3 lateinit变量未初始化,点击列表直接闪退
现象:Activity跳转列表页后,第一帧渲染就抛UninitializedPropertyAccessException闪退。
原因:某个分支漏了给lateinit字段赋值,Kotlin编译器无法验证所有运行时路径。
解决:换成by lazy懒加载,第一次访问时才创建实例,之后复用:
private val adapter: PersonAdapter by lazy { PersonAdapter(context) { person -> onPersonClick(person) } }以后遇到列表页空白或闪退,优先查Adapter是不是还在用lateinit。
5.4 旋转屏幕后输入内容全部清空
现象:旋转屏幕或从后台回来,EditText输入消失,列表滚动位置回到顶部。
原因:Activity重建后旧实例被销毁,代码没有做状态保存,临时数据全丢了。
解决:用ViewModel承载页面数据。ViewModel在配置变更后会存活,不会跟着Activity销毁。ch9的notes代码能看到实际案例,对比ch8的裸Activity写法,差异非常直观。
5.5 后台播放被系统杀掉
现象:按下Home键几分钟后播放中断,通知栏服务还在但不再出声。
原因:普通Service无法在后台长期驻留,还有各厂商省电策略的激进回收。
解决:播放服务改成前台服务并创建通知渠道,同时引导用户把App加入电池优化白名单。ADB命令可以验证后台存活状态:
# 启动App后按Home键回到桌面 adb shell am start -n com.example.player/.MainActivity adb shell input keyevent KEYCODE_HOME # 等待片刻后检查服务是否存活 adb shell dumpsys activity services com.example.player输出结果里如果服务还在运行、没有Killed记录,说明后台状态符合预期。这一步是排查后台被杀最直接的验证手段。
6. 把教学源码吃透:三个利用率最高的阅读技巧
6.1 先跑通再反向阅读
拿到压缩包,第一件事不是逐文件看代码,而是把整个工程导入开发环境,按章节顺序编译运行。跑通的意义在于建立基线:界面长什么样、交互顺序是什么。之后读代码才能在脑内把代码和界面映射上。这套源码的目录本身就是学习路线:ch2布局、ch3列表、ch4布局进阶、ch5数据、ch6到ch7业务演进、ch8到ch9数据存储、ch10多媒体。
6.2 做标记式改装
每个章节我都会做一个小改动:把people列表的点击从Toast改成跳详情页,把devices的删除从长按改成滑动条目,把notes列表的排序字段换一下。改完再对照原文看差异,你会发现每个模块的边界在哪:它把业务逻辑写在Activity里,你拆出去就要引入ViewModel;它把SQL写在Adapter附近,你就要考虑Repository。哪些改动牵一发动全身,跑一次改装就能摸清。
6.3 提炼最小可删骨架
最后一招最实用:每章读完,把代码删到不能再删。people模块最小骨架是数据库、DAO、列表Activity三件套;播放器最小骨架是MediaPlayer、前台服务、通知渠道三件套。往后做新项目时从最小骨架起步,再往里面填业务,比从零搭工程快得多。这套源码最好的用法不是收藏,而是当底稿反复拆装。
从那以后,我每次拿到教学源码都强制自己先跑通一遍,再做删改实验,没跑通的源码几乎不会花时间精读。希望这个习惯帮到你。
本文还有配套的精品资源,点击获取