Android日程App源码:用SQLite存数据,Fragment分页管理日程增删改查

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这个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身份的证明,storePasswordkeyPassword都设为”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");
    }
}
  1. 卸载旧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_ONLYAPK带有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 tabledatabase 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组件升级本项目,三步到位:

  1. 替换主题:在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>
  1. 升级控件
    - CalendarView → com.google.android.material.datepicker.MaterialDatePicker(支持范围选择、无障碍);
    - EditText → com.google.android.material.textfield.TextInputLayout(自带浮动标签);
    - FloatingActionButton → com.google.android.material.floatingactionbutton.FloatingActionButton(支持自定义图标和动画)。

  2. 适配深色模式

// 在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开发者的真正护城河。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这个Android日程管理项目提供完整可运行的源代码,界面基于原生日历控件搭建,用Fragment组织不同功能页面(如添加日程、查看当日列表、编辑详情),后端通过SQLite实现本地数据持久化。支持按日期筛选日程、新增条目时自动填充当前日期、点击日程进入编辑模式、单条删除或清空全部记录等基础操作。工程结构规范,包含标准Android Studio配置文件(build.gradle、settings.gradle、gradlew)、签名文件test.jks、混淆规则proguard-rules.pro、构建中间产物目录及资源文件路径(app/src/main/java/res)。适合刚接触Android开发的学习者练习Fragment生命周期配合数据库事务、日期选择器联动UI刷新、数据变更后列表实时更新等常见场景,无需联网或额外依赖即可编译运行。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
内容概要:本文详细记录了对一个Android ARM64静态ELF文件中字符串加密机制的逆向分析过程。该ELF文件的所有字符串均被加密,无法通过常规strings命令或IDA直接识别。作者通过分析发现,加密字符串储在.rodata段,其解密所需信息(包括密文地址、长度和16位密钥)保在.data.rel.ro段的40字节描述符中。核心解密函数sub_10F408采用自反的双pass流密码算法,结合固定密钥KEY_TERM(由.data段24字节数据计算得出),实现字节级非线性、位置与长度相关的加密。文章还复现了完整的Python解密脚本,并揭示了该保护机制的本质为代码混淆而非强加密,最终成功批量解密全部956条字符串,暴露程序真实行为,如shell命令模板、设备标识篡改、网络重置等操作。此外,文中还提及未启用的自定义壳框架及其反dump设计。; 适合人群:具备逆向工程基础的安全研究人员、二进制分析人员及对ELF保护技术感兴趣的开发者。; 使用场景及目标:①学习ELF二进制中字符串加密的典型实现方式与逆向突破口;②掌握从结构识别、函数追踪到算法还原的完整逆向流程;③理解“绑定二进制”的完整性校验设计及其局限性;④实践编写IDAPython脚本自动化提取与解密敏感数据。; 阅读建议:此资源以实战案例驱动,不仅展示技术细节,更强调逆向思维与验证方法,建议读者结合IDA调试环境,逐步跟随文中步骤进行动态分析与算法验证,深入理解每一步的推理依据。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值