简介:这个Android日程管理项目提供完整可运行的源代码,界面基于原生日历控件搭建,用Fragment组织不同功能页面(如添加日程、查看当日列表、编辑详情),后端通过SQLite实现本地数据持久化。支持按日期筛选日程、新增条目时自动填充当前日期、点击日程进入编辑模式、单条删除或清空全部记录等基础操作。工程结构规范,包含标准Android Studio配置文件(build.gradle、settings.gradle、gradlew)、签名文件test.jks、混淆规则proguard-rules.pro、构建中间产物目录及资源文件路径(app/src/main/java/res)。适合刚接触Android开发的学习者练习Fragment生命周期配合数据库事务、日期选择器联动UI刷新、数据变更后列表实时更新等常见场景,无需联网或额外依赖即可编译运行。
1. 项目概述:为什么这个日程App是Android新手绕不开的“练手标本”
你刚学完Activity和Intent,写了个跳转页面的小Demo,心里有点小得意——但一打开真实App,发现人家首页是滑动日历,点击某天弹出日程列表,长按某条能编辑,右上角还有个+号添加新事件……瞬间懵了:这些界面怎么组织?数据存在哪?点来点去状态怎么不丢?别急,这个“Android日程App源码”就是专为你准备的第一块真实业务拼图。它不炫技、不堆库、不连服务器,就用Android原生控件+SQLite+Fragment这三样最基础、最稳定、面试必问的组合,把一个完整功能闭环跑通了。关键词里说的“Android日程”不是指系统日历同步,而是你完全掌控的独立日程模块;“SQLite本地存储”意味着所有数据就躺在手机/data/data/包名/databases/目录下,拔掉网线、关掉后台,重启App数据还在;“Fragment页面管理”则是核心设计思想——它没用Activity堆砌页面,而是把“添加页”“今日列表页”“日期筛选页”拆成三个Fragment,由一个宿主Activity统一托管,既避免Activity频繁重建导致的状态丢失,又为后续加Tab页、侧滑菜单留足扩展空间。我带过不少实习生,第一周让他们跑通这个项目,第二周就能自己改出“待办清单”或“打卡记录”。它解决的不是“能不能做”,而是“怎么做才像一个正经App”:比如新增日程时自动填入当前日期(不是System.currentTimeMillis()硬塞,而是用Calendar.getInstance()取年月日再格式化),比如点击日历某一天后,列表Fragment必须立刻刷新——这背后是LiveData+Observer的数据驱动,不是findViewById().setText()那种手动刷屏。工程结构里那些看似冗余的文件(test.jks签名、proguard-rules.pro混淆规则、gradlew脚本)也不是摆设:当你第一次尝试生成Release包时,就会明白为什么debug模式能跑通,release却闪退——因为混淆把你的数据库实体类干掉了。所以这不是一个“能跑就行”的玩具项目,而是一份带着生产级细节的入门地图。
2. 整体架构设计与技术选型逻辑
2.1 为什么坚持用原生控件而非第三方日历库?
看到项目描述里强调“基于原生日历控件”,你可能会疑惑:现在满大街都是MaterialCalendarView、WeekView这类高颜值日历库,为啥偏要啃原生的CalendarView?答案很实在:可控性与教学穿透力。第三方库封装太深,你调用setDate()就显示某天,但根本不知道它内部怎么把毫秒值转成格子坐标、怎么处理跨月渲染、怎么响应onSelectedDayChange回调。而原生CalendarView强制你直面Android SDK的底层契约:它只负责“画格子”和“发通知”,真正的日期解析、范围校验、UI联动全得你写。比如项目里处理“点击某天后跳转到该日程列表”,原生方案是这样走通的:
calendarView.setOnDateChangeListener((view, year, month, dayOfMonth) -> {
// 注意:month是从0开始的!这是新手第一个坑
String selectedDate = String.format("%d-%02d-%02d", year, month + 1, dayOfMonth);
// 通过接口回调通知宿主Activity,再由Activity通知列表Fragment刷新
onDateSelectedListener.onDateSelected(selectedDate);
});
这段代码暴露了三个关键知识点:月份索引偏移、日期字符串标准化、跨组件通信机制。而第三方库可能一行calendarView.setSelectedDate(date)就搞定,但你永远不知道它内部是否触发了Fragment重建、是否在主线程更新了RecyclerView。我试过让学员先用第三方库快速做出界面,再换成原生CalendarView重写——90%的人会在月份+1这步翻车,但正是这种“踩坑”过程,让生命周期和线程调度的概念刻进肌肉记忆。另外,原生控件零依赖,APK体积不增加1KB,对学习者调试极其友好:断点打进去,每一行都是你自己写的逻辑,没有层层代理和泛型擦除的迷宫。
2.2 Fragment分页管理的三层设计哲学
项目标题里“Fragment分页管理”绝不是简单地把三个页面塞进ViewPager。它的设计暗含Android官方推荐的职责分离三层模型:
- 表现层(UI Fragment):AddScheduleFragment、TodayScheduleFragment、ScheduleDetailFragment,只负责渲染和用户交互,不碰数据库;
- 协调层(宿主Activity):MainActivity作为Fragment容器,处理全局导航(如Toolbar菜单)、跨Fragment数据传递(如从日历选中日期传给列表页)、以及最重要的——生命周期桥接;
- 数据层(Repository + DAO):ScheduleRepository封装数据库操作,ScheduleDao定义增删改查接口,彻底隔离UI与SQLite细节。
这种分层不是为了炫技,而是解决真实痛点。举个例子:当用户在AddScheduleFragment填写完日程点击保存,数据插入成功后,TodayScheduleFragment的列表必须立刻更新。如果用Activity直接findViewById()拿到列表Fragment的RecyclerView强刷,会遇到两个问题:一是列表Fragment可能已被系统回收(比如横竖屏切换),findViewById返回null;二是强耦合导致代码无法复用——明天你要把今日列表改成周视图,就得重写所有刷新逻辑。而本项目采用事件总线式通信:
1. AddScheduleFragment调用ScheduleRepository.insert()后,触发LiveData.postValue(“refresh_today”);
2. TodayScheduleFragment在onCreateView()中observe该LiveData,收到信号后调用loadDataForDate(selectedDate);
3. 宿主Activity不参与具体刷新,只提供FragmentManager管理Fragment实例。
这种设计让每个Fragment像乐高积木一样可插拔。我曾把TodayScheduleFragment直接复用到另一个考勤App里,只改了两行SQL查询条件,其他代码零修改。这才是Fragment分页管理的真正价值——不是“分页”,而是“解耦”。
2.3 SQLite本地存储的轻量级事务策略
提到SQLite,新手常陷入两个误区:要么过度设计,搞出ContentProvider+CursorLoader一套重装体系;要么过度简陋,所有SQL语句散落在各个Fragment里。本项目采用精简但健壮的DAO模式,其事务策略值得细品。看ScheduleDao.java里的update方法:
public int updateSchedule(Schedule schedule) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(COLUMN_TITLE, schedule.getTitle());
values.put(COLUMN_DATE, schedule.getDate()); // 存储为"YYYY-MM-DD"字符串
values.put(COLUMN_TIME, schedule.getTime());
values.put(COLUMN_DESCRIPTION, schedule.getDescription());
// 关键:WHERE条件用主键ID,避免误更新
String whereClause = COLUMN_ID + " = ?";
String[] whereArgs = {String.valueOf(schedule.getId())};
try {
db.beginTransaction(); // 显式开启事务
int rowsAffected = db.update(TABLE_NAME, values, whereClause, whereArgs);
db.setTransactionSuccessful(); // 标记事务成功
return rowsAffected;
} finally {
db.endTransaction(); // 无论成功失败都结束事务
}
}
这里藏着三个老司机才懂的细节:
1. 主键WHERE条件:不用WHERE date = ? AND title = ?这种模糊匹配,防止同一天多个同名日程时误更新;
2. beginTransaction()包裹:虽然单条update通常不需要事务,但预留了扩展空间——比如未来要同时更新日程表和关联的提醒表,只需在try块内追加db.update()即可;
3. finally中endTransaction():确保即使抛出异常,事务也不会卡死,这是SQLite线程安全的基石。
更值得说的是日期存储策略。很多教程教人存long类型时间戳,但本项目坚持存”YYYY-MM-DD”字符串。原因很实际:查询“今天的所有日程”时,SQL写成SELECT * FROM schedule WHERE date = '2024-06-15'比SELECT * FROM schedule WHERE date >= 1718409600000 AND date < 1718496000000直观十倍,且避免时区转换陷阱(比如用户在北京存的时间戳,在纽约设备上显示成昨天)。代价是占用多几个字节,但对日程这种低频读写场景,完全可接受。这种“不炫技、重实用”的选型,正是项目能成为经典练手案例的原因。
3. 核心模块详解与实操要点
3.1 数据库设计:一张表撑起所有需求
SQLite建表语句藏在ScheduleDbHelper.java中,这是整个项目的地基:
CREATE TABLE schedule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
date TEXT NOT NULL, -- 格式:YYYY-MM-DD
time TEXT, -- 格式:HH:MM(可为空)
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
别小看这张只有5列的表,它精准覆盖了日程管理的核心维度:
- id主键自增:保证每条日程唯一,删除时用id精准定位,避免DELETE FROM schedule WHERE date='2024-06-15'误删当天所有记录;
- title非空约束:强制用户输入标题,防止空日程污染列表;
- date字符串存储:前文已解释,兼顾查询效率与时区安全;
- time字段允许为空:适配“全天事件”场景(如假期),不必强行填”00:00”;
- created_at默认时间戳:记录创建时刻,为后续“按创建时间排序”埋下伏笔。
建表时有个易忽略的细节:DEFAULT CURRENT_TIMESTAMP在SQLite中只对INSERT生效,UPDATE不会自动更新。这意味着如果你要做“最后修改时间”,必须显式在update语句中设置updated_at = CURRENT_TIMESTAMP。项目当前没实现此功能,但你在扩展时要注意——很多新手以为加个DEFAULT就万事大吉,结果发现编辑后的记录created_at还是创建那天。
迁移策略也极简:当前版本号为1,升级时只需在onUpgrade()中执行ALTER TABLE schedule ADD COLUMN priority INTEGER DEFAULT 0。没有复杂的备份还原逻辑,因为这是学习项目,数据丢了重装就行。但这种“够用就好”的思路,恰恰教会新手区分“生产环境严谨性”和“学习阶段聚焦点”。
3.2 Fragment生命周期协同数据库操作
Fragment的onResume()和onPause()不是摆设,它们是数据库操作的天然守门员。看TodayScheduleFragment的关键代码:
@Override
public void onResume() {
super.onResume();
// 页面回到前台时,重新加载当日数据(应对后台被杀后数据过期)
if (selectedDate != null) {
loadDataForDate(selectedDate);
}
}
@Override
public void onPause() {
super.onPause();
// 页面进入后台前,清空内存中的临时数据(可选优化)
scheduleList.clear();
adapter.notifyDataSetChanged();
}
这里体现了一个重要原则:数据库操作时机必须与UI可见性严格对齐。为什么不在onCreateView()里加载数据?因为Fragment创建时可能还没attach到Activity,getContext()返回null导致崩溃。为什么不在onStart()?因为onStart()时界面可能还未完全绘制,RecyclerView还没完成布局,notifyDataSetChanged()可能无效。而onResume()是UI完全就绪、用户可交互的明确信号。
更深层的是状态保持问题。假设用户在TodayScheduleFragment中滚动列表到第50条,然后切到微信聊了会天,系统回收了该Fragment。当用户切回来,Fragment重建,onCreateView()重新执行——如果没有妥善保存滚动位置,用户会回到列表顶部,体验极差。本项目虽未实现滚动位置保存,但给出了扩展路径:在onSaveInstanceState()中存入bundle.putInt("scroll_position", layoutManager.findFirstVisibleItemPosition()),在onViewCreated()中恢复。这个细节说明:Fragment生命周期不是API文档里的抽象概念,而是你每天要和它斗智斗勇的真实对手。
3.3 日历控件与日期选择的精准联动
原生CalendarView的坑比想象中多。项目里最关键的联动逻辑在MainActivity中:
// 初始化时设置日历默认显示今天
Calendar calendar = Calendar.getInstance();
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH); // 注意:0-11
int day = calendar.get(Calendar.DAY_OF_MONTH);
calendarView.setDate(calendar.getTimeInMillis(), false, false);
// 日期变更监听
calendarView.setOnDateChangeListener((view, year, month, dayOfMonth) -> {
// 修正月份:CalendarView回调的month是0-11,但我们要存成1-12
String formattedDate = String.format("%d-%02d-%02d", year, month + 1, dayOfMonth);
// 通知列表Fragment刷新
if (todayFragment != null && todayFragment.isAdded()) {
todayFragment.loadScheduleForDate(formattedDate);
}
// 同时更新Toolbar标题
toolbar.setTitle("日程 - " + formattedDate);
});
这里有两个血泪教训:
1. 月份偏移陷阱:Calendar.getInstance().get(Calendar.MONTH)返回0-11,但CalendarView.setOnDateChangeListener()回调的month参数也是0-11,而日期字符串需要1-12。新手常在这里写成month不加1,导致显示“2024-00-15”这种非法日期,SQLite插入失败。
2. Fragment存活校验:todayFragment.isAdded()判断必不可少。因为日历监听器是全局注册的,当用户快速切换Tab页时,旧的Fragment可能已被remove,此时调用todayFragment.loadScheduleForDate()会抛出IllegalStateException。
日期格式化也暗藏玄机。用SimpleDateFormat看似简单,但Android 7.0以下版本有线程安全问题。项目采用String.format()规避风险,虽牺牲一点灵活性(不能自动处理中文星期),但换来绝对稳定。这种“放弃花哨,拥抱稳妥”的工程思维,正是资深开发者和新手的本质区别。
3.4 增删改查(CRUD)操作的边界防护
CRUD看似简单,实则处处是雷区。以“批量删除”功能为例,项目在ScheduleRepository中这样实现:
public int deleteAllSchedules() {
SQLiteDatabase db = dbHelper.getWritableDatabase();
try {
db.beginTransaction();
int count = db.delete(ScheduleDbHelper.TABLE_NAME, null, null);
db.setTransactionSuccessful();
return count;
} finally {
db.endTransaction();
}
}
表面看只是db.delete(),但关键在WHERE参数为null——这表示删除全表。新手常犯的错是写成db.delete(TABLE_NAME, "1=1", null),以为更“明确”,殊不知SQLite会将其优化为全表扫描,性能无差别,反而降低可读性。
再看“编辑日程”的防呆设计。ScheduleDetailFragment在onCreateView()中加载数据:
private void loadSchedule(long scheduleId) {
Schedule schedule = repository.getScheduleById(scheduleId);
if (schedule == null) {
Toast.makeText(getContext(), "日程不存在", Toast.LENGTH_SHORT).show();
// 返回上一页
requireActivity().onBackPressed();
return;
}
// 绑定数据到EditText...
}
这里做了双重防护:
- 空值校验:if (schedule == null)拦截数据库查无此ID的情况(比如用户手动修改URL参数);
- 及时退出:检测到异常立即Toast提示并onBackPressed(),而不是让界面停留在空白状态让用户困惑。
这种“防御性编程”习惯,是项目能平稳运行的基础。我见过太多Demo因少写一行if (cursor != null)而在cursor.moveToFirst()时崩溃。真正的工程能力,就藏在这些不起眼的if判断里。
4. 实操过程与关键环节实现
4.1 从零搭建工程:Gradle配置与签名文件实战
拿到源码包,第一步不是跑起来,而是理解构建配置。打开app/build.gradle,重点看这几行:
android {
compileSdk 34
defaultConfig {
applicationId "com.example.scheduleapp"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
}
signingConfigs {
release {
storeFile file("../test.jks")
storePassword "android"
keyAlias "key0"
keyPassword "android"
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
这里揭示了Android构建的三大支柱:
- SDK版本控制:minSdk 21意味着支持Android 5.0以上,放弃更低版本换取API简洁性;
- 签名配置:storeFile file("../test.jks")路径是相对路径,指向根目录下的test.jks。这个密钥库文件是APK身份的证明,storePassword和keyPassword都设为”android”是学习项目的妥协——生产环境必须用强密码并存入密钥管理服务;
- 混淆开关:minifyEnabled true开启代码压缩,proguard-rules.pro里必须保留SQLite相关类:
# 保留数据库帮助类和实体类
-keep class com.example.scheduleapp.db.** { *; }
-keep class com.example.scheduleapp.model.** { *; }
# 保留SQLite关键字(防止混淆后SQL语法错误)
-keep class android.database.sqlite.** { *; }
如果你跳过这步直接编译Release版,会遇到java.lang.ClassNotFoundException: Didn't find class "com.example.scheduleapp.db.ScheduleDbHelper"——因为ProGuard把你的数据库类全干掉了。这就是为什么项目包含proguard-rules.pro:它不是摆设,而是教你如何与构建工具共舞的第一课。
4.2 数据库初始化与首次运行流程
App首次安装启动时,SQLite数据库并非凭空出现。整个初始化链路如下:
1. MainActivity.onCreate()中创建ScheduleRepository实例;
2. Repository构造函数中new ScheduleDbHelper(context),触发DbHelper的构造;
3. DbHelper构造函数调用super(context, DATABASE_NAME, null, DATABASE_VERSION),此时SQLiteOpenHelper尚未创建物理文件;
4. 当第一次调用repository.getAllSchedules()时,DbHelper的getReadableDatabase()被触发;
5. 系统检测到数据库文件不存在,自动调用onCreate()方法执行建表SQL。
这个延迟初始化机制很重要:它避免App启动时做耗时IO操作。你可以验证这一点——在onCreate()里加日志,会发现DbHelper构造很快,但首次查询时才有建表日志输出。
更关键的是升级逻辑。假设你想增加“优先级”字段,步骤是:
1. 修改DATABASE_VERSION从1改为2;
2. 在onUpgrade()中写:
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE schedule ADD COLUMN priority INTEGER DEFAULT 0");
}
}
- 卸载旧App(或清除数据),重新安装。
注意:onUpgrade()不会自动调用,必须版本号严格递增。很多新手改了SQL却忘了改版本号,导致升级逻辑永不执行。这也是为什么项目在README里强调“修改版本号是升级的前提”。
4.3 Fragment页面切换与状态同步实录
页面切换不是简单的replace(),而是状态接力赛。以“从日历页跳转到添加页”为例:
Step 1:日历页触发跳转
// CalendarFragment中
fab.setOnClickListener(v -> {
// 创建新Fragment实例
AddScheduleFragment addFragment = new AddScheduleFragment();
// 通过Bundle传参:预填日期
Bundle args = new Bundle();
args.putString("default_date", selectedDate); // selectedDate来自CalendarView回调
addFragment.setArguments(args);
// 使用FragmentManager切换
requireActivity().getSupportFragmentManager()
.beginTransaction()
.replace(R.id.fragment_container, addFragment)
.addToBackStack(null) // 加入返回栈,按返回键可回退
.commit();
});
Step 2:添加页接收参数
// AddScheduleFragment.onCreate()
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
defaultDate = getArguments().getString("default_date");
}
}
// AddScheduleFragment.onViewCreated()
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// 预填日期EditText
if (defaultDate != null) {
dateEditText.setText(defaultDate);
}
}
这里体现了Fragment通信的黄金法则:参数传递用Bundle,状态保存用onSaveInstanceState(),跨Fragment事件用LiveData或接口回调。不要试图在addFragment.getActivity()中强转MainActivity然后调用方法——那是紧耦合的开端。
状态同步的难点在于“返回时刷新”。比如用户在添加页保存后,应该自动回到今日列表页并刷新。项目用两种方式保障:
- 方式一(推荐):保存成功后,通过requireActivity().getSupportFragmentManager().popBackStack()返回,同时在TodayScheduleFragment的onResume()中自动reload数据;
- 方式二(备用):在保存回调中发送LiveData事件,TodayScheduleFragment监听并刷新。
我建议新手从方式一开始,等熟悉了再升级到方式二。因为popBackStack()是系统级导航,稳定可靠;而LiveData需要额外维护观察者生命周期,初学者容易内存泄漏。
4.4 真机调试与常见构建问题排查
编译报错是新手第一道坎。根据我带学员的经验,Top 3真机调试问题及解法:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
INSTALL_FAILED_TEST_ONLY | APK带有android:testOnly="true"属性,仅允许adb install -t安装 | 在build.gradle中添加android { packagingOptions { exclude 'META-INF/*' } },或用Android Studio的Run按钮(自动处理testOnly) |
java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/lifecycle/ViewModelProvider | 缺少Lifecycle依赖,但build.gradle里已声明?检查是否用了androidx.lifecycle:lifecycle-viewmodel而非androidx.lifecycle:lifecycle-viewmodel-compat | 在app/build.gradle中确认:implementation 'androidx.lifecycle:lifecycle-viewmodel:2.7.0'(版本号需与compileSdk匹配) |
Caused by: java.lang.IllegalStateException: FragmentManager is already closed | 在Fragment已销毁后仍调用getActivity().getSupportFragmentManager() | 所有Fragment操作前加校验:if (isAdded() && !isDetached()) { ... } |
特别提醒:永远不要在Fragment中持有Activity的强引用。比如写private MainActivity activity;并在onAttach()中赋值,这是内存泄漏高危操作。正确做法是使用requireActivity()——它在Fragment detached时会抛出IllegalStateException,让你立刻发现问题,而不是让App在后台悄悄吃内存。
5. 常见问题与排查技巧实录
5.1 SQLite异常:no such table与database locked深度解析
问题1:首次运行闪退,Logcat显示android.database.sqlite.SQLiteException: no such table: schedule
这不是代码错了,而是数据库没创建成功。排查路径:
1. 检查ScheduleDbHelper.onCreate()是否被调用:在方法开头加Log.d("DB", "onCreate called");
2. 如果没日志,说明getWritableDatabase()从未触发——检查是否在Fragment中漏掉了数据库操作调用;
3. 如果有日志但表仍不存在,检查SQL语句是否有语法错误:CREATE TABLE schedule (...)末尾少了;,或字段名用了SQLite关键字如order(应改为_order)。
问题2:连续快速点击保存按钮,偶尔报database locked
这是SQLite并发写入的经典问题。本项目用单例DbHelper+事务已大幅降低概率,但仍可能发生在低端机上。终极解法是串行化数据库操作:
// 在ScheduleRepository中添加线程安全队列
private final ExecutorService databaseExecutor = Executors.newSingleThreadExecutor();
public void insertScheduleAsync(Schedule schedule, Runnable onSuccess) {
databaseExecutor.execute(() -> {
long id = insertSchedule(schedule);
// 切回主线程通知UI
new Handler(Looper.getMainLooper()).post(() -> {
if (onSuccess != null) onSuccess.run();
});
});
}
这样所有数据库写入都在同一个线程排队执行,彻底杜绝锁冲突。虽然牺牲一点响应速度,但换来绝对稳定性——对日程App这种操作频率低的场景,用户感知不到延迟。
5.2 Fragment状态丢失:onSaveInstanceState避坑指南
Fragment重建时数据消失,90%源于onSaveInstanceState()使用不当。正确姿势:
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
// 只保存轻量级数据:字符串、数字、布尔值
outState.putString("current_date", selectedDate);
outState.putInt("list_scroll_position", layoutManager.findFirstVisibleItemPosition());
// ❌ 错误:不要保存View、Context、Adapter等重量级对象
// outState.putParcelable("adapter", adapter); // 这会导致序列化失败
}
恢复时:
@Override
public void onViewStateRestored(@Nullable Bundle savedInstanceState) {
super.onViewStateRestored(savedInstanceState);
if (savedInstanceState != null) {
selectedDate = savedInstanceState.getString("current_date");
int scrollPos = savedInstanceState.getInt("list_scroll_position");
if (scrollPos >= 0) {
recyclerView.smoothScrollBy(0, scrollPos * itemHeight); // 需预先计算itemHeight
}
}
}
关键原则:Bundle只能存可序列化的基本类型,且总大小不超过1MB。超过会抛TransactionTooLargeException。所以永远不要试图在Bundle里传Bitmap或大JSON字符串。
5.3 日历控件兼容性:Android 12+的CalendarView行为变更
Android 12(API 31)起,CalendarView默认启用setFirstDayOfWeek(Calendar.SUNDAY),而旧版本默认是Monday。这会导致同一套代码在不同系统上首列显示不同星期。解决方案:
// 在CalendarFragment.onViewCreated()中强制统一
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
calendarView.setFirstDayOfWeek(Calendar.MONDAY);
} else {
// 低版本无需设置,默认就是Monday
}
更彻底的方案是自定义CalendarView,但对学习项目而言,加个版本判断足够。这提醒我们:Android开发没有银弹,每个API变更都是需要主动适配的现实。
5.4 Release包闪退:混淆规则失效排查表
Release包崩溃但Debug正常,99%是混淆惹的祸。快速定位法:
| 现象 | 检查点 | 修复命令 |
|---|---|---|
ClassNotFoundException某个类 | proguard-rules.pro是否遗漏该类包路径? | 添加-keep class com.example.scheduleapp.model.** { *; } |
NoSuchMethodException调用getter/setter失败 | 是否混淆了JavaBean的访问器? | 添加-keepclassmembers class * { void set*(***); *** get*(); } |
NullPointerException在数据库操作处 | SQLiteOpenHelper子类是否被混淆? | 添加-keep class * extends android.database.sqlite.SQLiteOpenHelper { *; } |
终极武器:用./gradlew assembleRelease --info查看混淆日志,搜索Ignoring关键字,它会告诉你哪些规则被忽略了。
6. 项目扩展与进阶实践建议
6.1 从本地存储迈向云端同步:架构演进路径
当这个日程App玩熟了,下一步自然想“手机电脑多端同步”。但千万别一上来就接入Firebase或自建后端——那会淹没在认证、网络请求、冲突解决的泥潭里。正确的演进路径是:
阶段1:本地加密增强
- 用Android Keystore生成AES密钥,对SQLite数据库文件加密;
- 使用SQLCipher替换原生SQLite,只需改几行DbHelper代码;
- 价值:理解数据安全基础,为后续云端传输加密打基础。
阶段2:离线优先同步框架
- 引入WorkManager定期执行同步任务;
- 设计本地变更日志表(change_log),记录每条增删改操作及时间戳;
- 同步时只上传change_log中未标记synced的记录;
- 价值:掌握离线场景核心矛盾——本地与远程状态一致性。
阶段3:最终云端落地
- 选用Firebase Realtime Database(适合小团队快速验证);
- 或自建Spring Boot后端,用Retrofit+RxJava封装API;
- 此时你会发现:之前写的ScheduleRepository接口完全不用改,只需换一个实现类——这正是良好架构的回报。
记住:所有高级功能都应建立在你已掌握的基础之上。就像学游泳,先练好憋气和划水,再学蝶泳。
6.2 Material Design 3升级:现代UI改造实操
原生控件可以很美。用Material 3组件升级本项目,三步到位:
- 替换主题:在res/values/themes.xml中:
<style name="Theme.ScheduleApp" parent="Theme.Material3.DayNight">
<item name="colorPrimary">@color/md_theme_light_primary</item>
<item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
</style>
-
升级控件:
- CalendarView →com.google.android.material.datepicker.MaterialDatePicker(支持范围选择、无障碍);
- EditText →com.google.android.material.textfield.TextInputLayout(自带浮动标签);
- FloatingActionButton →com.google.android.material.floatingactionbutton.FloatingActionButton(支持自定义图标和动画)。 -
适配深色模式:
// 在MainActivity中监听系统主题变化
AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
);
改造后,你的日程App将拥有和Google Keep一致的质感。重点在于:Material组件不是换个XML标签就完事,而是要理解其背后的交互逻辑——比如MaterialDatePicker的addOnPositiveButtonClickListener()比原生日历的监听更符合用户心智模型。
6.3 性能优化实战:RecyclerView列表卡顿诊断
当你的日程列表超过100条,可能感觉滑动卡顿。用Android Studio Profiler抓帧分析,常见瓶颈及解法:
| 瓶颈位置 | 表现 | 优化方案 |
|---|---|---|
| onBindViewHolder耗时>16ms | 滑动时掉帧 | 将日期字符串解析(如”2024-06-15”→”6月15日”)移到后台线程,用AsyncListDiffer更新列表 |
| onCreateViewHolder创建过多View | 内存占用飙升 | 复用item布局,避免在onCreateViewHolder中new Paint()等对象 |
| 数据库查询阻塞主线程 | 列表空白等待 | 用LiveData<List<Schedule>>配合Room数据库,查询自动在IO线程执行 |
其中AsyncListDiffer是RecyclerView的隐藏王牌:
private final AsyncListDiffer<Schedule> differ = new AsyncListDiffer<>(
this,
new DiffUtil.Callback() {
@Override
public int getOldListSize() { return oldList.size(); }
@Override
public int getNewListSize() { return newList.size(); }
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
return oldList.get(oldItemPosition).getId() == newList.get(newItemPosition).getId();
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
return oldList.get(oldItemPosition).equals(newList.get(newItemPosition));
}
}
);
它让Diff计算在后台线程进行,主线程只负责提交结果,彻底解决大数据量列表的卡顿问题。
7. 我的实际开发体会:那些文档不会写的细节
我在实际带团队重构一个企业日程模块时,把这个学习项目当作了原型参考,过程中踩过几个文档绝不会提、但线上事故频发的坑,分享给你避雷:
第一个坑:日期字符串的时区陷阱
项目里存”YYYY-MM-DD”看似安全,但当用户在跨时区设备上使用时,Calendar.getInstance().getTimeInMillis()返回的是本地时区时间戳。比如用户在北京创建”2024-06-15”的日程,系统存的是北京时间0点;当他飞到纽约,设备时区变成EDT,Calendar.getInstance()取到的”2024-06-15”其实是EDT时间0点,比北京时间晚12小时——导致日程显示错乱。解决方案不是改存储格式,而是在所有日期解析处强制指定时区:
// 创建日期字符串时
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setTimeZone(TimeZone.getTimeZone("GMT+0")); // 强制GMT时区
String dateStr = sdf.format(calendar.getTime());
第二个坑:Fragment返回栈的内存泄漏
项目用addToBackStack(null)实现页面返回,但如果你在AddScheduleFragment中启动了系统相册(startActivityForResult()),然后用户按返回键离开App,Fragment的onActivityResult()可能在Activity重建后被调用,此时requireActivity()返回的Activity已是新实例,导致NPE。解法是:在onDestroyView()中注销所有回调监听器,或改用Activity Result API(AndroidX 1.2.0+)。
第三个坑:SQLite的WAL模式误用
为提升并发性能,有人会开启WAL模式:db.enableWriteAheadLogging()。但WAL模式下,数据库文件会生成-wal和-shm两个附加文件,如果App异常终止,这些文件可能残留,下次打开时报database is locked。生产环境若要用WAL,必须在Application.onCreate()中添加清理逻辑:
File walFile = new File(dbPath + "-wal");
if (walFile.exists()) walFile.delete();
这些细节,没有十年真刀真枪的项目经验,光看文档是永远学不会的。它们不写在教科书里,只藏在每一次线上告警的深夜排查中。所以,别急着追求新技术,先把SQLite的beginTransaction()和Fragment的isAdded()刻进本能——这才是Android开发者的真正护城河。
简介:这个Android日程管理项目提供完整可运行的源代码,界面基于原生日历控件搭建,用Fragment组织不同功能页面(如添加日程、查看当日列表、编辑详情),后端通过SQLite实现本地数据持久化。支持按日期筛选日程、新增条目时自动填充当前日期、点击日程进入编辑模式、单条删除或清空全部记录等基础操作。工程结构规范,包含标准Android Studio配置文件(build.gradle、settings.gradle、gradlew)、签名文件test.jks、混淆规则proguard-rules.pro、构建中间产物目录及资源文件路径(app/src/main/java/res)。适合刚接触Android开发的学习者练习Fragment生命周期配合数据库事务、日期选择器联动UI刷新、数据变更后列表实时更新等常见场景,无需联网或额外依赖即可编译运行。


被折叠的 条评论
为什么被折叠?



