简介:在移动应用开发中,Android的组件化架构与本地数据持久化是构建稳定应用的基础。Activity负责界面生命周期管理,Fragment实现模块化布局切换,而SQLite则为离线业务提供轻量级数据存储方案。RecyclerView作为现代列表控件,搭配Adapter与DiffUtil能够高效渲染动态数据,解决了传统ListView的性能瓶颈。这一系列技术原理构成了电商类应用的通用骨架,广泛适用于二手交易、社交分享、内容社区等场景。本文以二手交易系统为例,从业务表设计、DAO层封装、登录会话持久化,到Fragment容器切换、相册权限适配、图片压缩与通知渠道,完整演示了如何将Android核心组件串联成一条可运行的业务线。对于正在准备毕业设计或希望补齐Android基础知识的开发者,通过本案例可以快速掌握SQLite数据库操作、Fragment通信机制以及运行时权限适配等关键工程实践,从而在真实项目中避免常见的生命周期与内存泄漏问题。
1. 二手交易系统:把安卓四大组件串成一条业务线
作为安卓毕设,二手交易系统最值得做的地方不在功能多,而在它天然覆盖了 Android 的基础知识闭环。欢迎页要处理 Splash 与延迟跳转,登录注册要操作 SQLite 并持久化用户态,首页四个分类要驱动 Fragment 切换,发布闲置要涉及相册选图、图片压缩和状态刷新,退出登录又要借助广播让所有界面同步收尾。做完这一套,你对 Activity 生命周期、DAO 层封装和 RecyclerView 刷新的理解,比做十个记账本都扎实。
这套代码基于 Android Studio 生成,数据库采用 SQLite 手写 DAO,没有引入 Room,目的是把原始 SQL 和 Cursor 的用法暴露出来。适合作毕业设计答辩演示,也适合想补 Android 基础的在职开发。下面的章节会按数据层、界面层、业务层的顺序拆,每个部分都给出可以直接抄进项目的代码和参数说明。
2. SQLite 四张业务表与手写 DAO 的设计取舍
二手交易系统的数据关系并不复杂,但表与表之间的外键关系如果第一版没定好,后面加“我的收藏”“我的评论”时就要反复改 SQL。我的习惯是先按业务路径倒推:注册产生 user,发布产生 goods,收藏和评论是 user 与 goods 的多对多关联。这样最少四张业务表,再加一张分类字典表,一共五张。
2.1 从发布、收藏和评论反推表结构
商品表里的uid指向发布者,cid指向分类;收藏表和评论表都同时携带uid和goods_id。这样“某某收藏了什么”“谁评论了什么商品”都能通过一条 JOIN 查出来。
-- 用户表 CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, phone TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); -- 物品分类表 CREATE TABLE category ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE ); -- 闲置商品表 CREATE TABLE goods ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid INTEGER NOT NULL, cid INTEGER NOT NULL, title TEXT NOT NULL, detail TEXT, price REAL, image_path TEXT, contact TEXT, status INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (uid) REFERENCES user(id), FOREIGN KEY (cid) REFERENCES category(id) ); -- 收藏表 CREATE TABLE favorite ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid INTEGER NOT NULL, goods_id INTEGER NOT NULL, created_at TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (uid) REFERENCES user(id), FOREIGN KEY (goods_id) REFERENCES goods(id) ); -- 评论表 CREATE TABLE comment ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid INTEGER NOT NULL, goods_id INTEGER NOT NULL, content TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (uid) REFERENCES user(id), FOREIGN KEY (goods_id) REFERENCES goods(id) );这里有几个容易忽略的设计点。goods.price用REAL而不是INTEGER,因为二手价格经常出现 9.9、19.9 这样的小数;status字段默认 0,表示上架中,1 表示已下架,首页列表查询时要把下架商品过滤掉;created_at用datetime('now', 'localtime')存的是本地时间,而不是 UTC,避免展示时换算出错。
| 表名 | 承担的业务动作 | 关键字段 |
|---|---|---|
user | 注册、登录、个人中心 | username、password |
goods | 首页列表、发布、我的闲置 | uid、cid、status、price |
favorite | 我的收藏 | uid、goods_id |
comment | 商品详情、我的评论 | uid、goods_id、content |
收藏和评论都只存关联 ID,不冗余商品标题和用户昵称。这样商品改名、用户改昵称时不需要同步更新收藏表和评论表,最多在查询时 JOIN 一次。
2.2 手写 SQLiteOpenHelper 与 DAO 层的原因
毕设项目里不用 Room 或 LitePal,答辩时反而更容易讲清楚 SQL 与 Cursor 的关系。SQLiteOpenHelper 负责数据库版本管理,DAO 类负责具体的增删改查,界面层不直接拿 SQLiteDatabase。
public class DBHelper extends SQLiteOpenHelper { public DBHelper(Context context) { // 参数:上下文、数据库文件名、游标工厂、版本号 super(context.getApplicationContext(), "secondhand.db", null, 1); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_USER_TABLE); db.execSQL(CREATE_CATEGORY_TABLE); db.execSQL(CREATE_GOODS_TABLE); db.execSQL(CREATE_FAVORITE_TABLE); db.execSQL(CREATE_COMMENT_TABLE); ContentValues v = new ContentValues(); v.put("name", "学习"); db.insert("category", null, v); // 生活、体育、电子同理,重复代码可以抽成循环 } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE goods ADD COLUMN contact TEXT"); } } }onUpgrade里不要直接 DROP 整张表,除非你用不到老数据。常见做法是写一个DBManager单例,内部持有DBHelper,所有 DAO 方法都在这个单例上操作。注意super(context.getApplicationContext(), ...)这行,SQLiteOpenHelper是长生命周期对象,传入 Activity 会造成内存泄漏。
提示:实例化
SQLiteOpenHelper时使用context.getApplicationContext(),避免持有了 Activity 而造成泄漏,这在长生命周期的 DAO 单例里经常被忽略。
2.3 商品列表查询 SQL:LEFT JOIN 与 Cursor 映射
首页按分类取商品列表时,需要同时显示发布者昵称,这时候要 LEFT JOINuser表。用?占位符而不是字符串拼接,可以防 SQL 注入,也是登录查询的正确写法。
public List<Goods> queryGoodsByCategory(int cid) { List<Goods> result = new ArrayList<>(); String sql = "SELECT g.*, u.username AS seller FROM goods g " + "LEFT JOIN user u ON g.uid = u.id " + "WHERE g.cid = ? AND g.status = 0 " + "ORDER BY g.created_at DESC"; Cursor c = getReadableDatabase().rawQuery(sql, new String[]{String.valueOf(cid)}); while (c.moveToNext()) { Goods g = new Goods(); g.setId(c.getInt(c.getColumnIndexOrThrow("id"))); g.setTitle(c.getString(c.getColumnIndexOrThrow("title"))); g.setPrice(c.getDouble(c.getColumnIndexOrThrow("price"))); g.setSellerName(c.getString(c.getColumnIndexOrThrow("seller"))); result.add(g); } c.close(); return result; }LEFT JOIN 比 INNER JOIN 更安全,用户被删除后商品仍然能显示,只是seller是 null。Cursor 的getColumnIndexOrThrow可以避免手写列名下标导致数组越界。实际项目里,c.close()放在 finally 块中更保险。
3. 登录注册与会话持久化的落地姿势
登录注册是这个项目的入口,也是门槛。很多同学能跑通注册,但记不住密码、退出后状态丢失、返回键直接退出 App,这些问题都出在会话管理上。
3.1 注册时加密,登录时只比对摘要
不要在 SQLite 里直接存明文密码。毕设不需要上 bcrypt,MD5 加盐已经比明文强一个档次。盐值固定写死在代码里,生产环境则要给每个用户生成随机盐并单独存储。
public long register(String name, String pwd, String phone) { SQLiteDatabase db = getWritableDatabase(); ContentValues values = new ContentValues(); values.put("username", name); // 加盐后再 md5,避免简单的彩虹表攻击 values.put("password", Md5Util.md5(pwd + "secondhand_salt")); values.put("phone", phone); return db.insert("user", null, values); } public User login(String name, String pwd) { String sql = "SELECT * FROM user WHERE username = ? AND password = ?"; Cursor c = getReadableDatabase().rawQuery( sql, new String[]{name, Md5Util.md5(pwd + "secondhand_salt")}); if (c.moveToFirst()) { User u = new User(); u.setId(c.getInt(c.getColumnIndexOrThrow("id"))); u.setUsername(c.getString(c.getColumnIndexOrThrow("username"))); c.close(); return u; } c.close(); return null; }登录按钮的点击事件要放到子线程。SQLite 查询虽然很快,但 UI 线程不允许执行网络和磁盘 IO,Android Studio 的 StrictMode 在 Debug 包下会直接弹黄屏警告。
btnLogin.setOnClickListener(v -> { String name = etUsername.getText().toString().trim(); String pwd = etPassword.getText().toString().trim(); if (TextUtils.isEmpty(name) || TextUtils.isEmpty(pwd)) { Toast.makeText(this, "用户名和密码不能为空", Toast.LENGTH_SHORT).show(); return; } new Thread(() -> { final User user = dao.login(name, pwd); runOnUiThread(() -> { if (user != null) { // UserHolder 保存当前登录用户,后面发布和收藏都要用 uid UserHolder.getInstance().setUser(user); startActivity(new Intent(LoginActivity.this, MainActivity.class)); finish(); } else { Toast.makeText(LoginActivity.this, "账号或密码错误", Toast.LENGTH_SHORT).show(); } }); }).start(); });UserHolder是一个持有静态User对象的单例,作用域是整个 App 进程。退出登录时要调用clear(),否则切账号后发布的商品会落到上一个用户头上。
3.2 记住密码用 SharedPreferences 而非数据库
“记住密码”的本质是本地持久化。这里用SharedPreferences最合适,文件存放在/data/data/包名/shared_prefs/下,不需要建表。
// 登录成功后写 SharedPreferences sp = getSharedPreferences("login_pref", Context.MODE_PRIVATE); sp.edit() .putBoolean("remember", cbRemember.isChecked()) .putString("username", name) .putString("password", cbRemember.isChecked() ? Md5Util.md5(pwd + "salt") : "") .apply(); // 下次打开登录页时读 String savedName = sp.getString("username", ""); String savedPwd = sp.getString("password", ""); etUsername.setText(savedName); etPassword.setText(savedPwd);apply()是异步写内存和磁盘,commit()是同步写盘。登录场景用apply()足够,不会卡 UI。Demo 里保存 MD5 摘要是一种折中;真正的商业 App 应该用EncryptedSharedPreferences或系统的 Account Manager。
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 记住密码重启后不生效 | 登录成功分支没写sp.edit().apply() | 在finish()前统一保存 |
| 登录成功却跳回登录页 | UserHolder被清空或没有注册 | 在 Splash 判断是否已有会话 |
| 密码带空格也能登录 | 输入框没有trim() | 注册和登录都先 trim |
| 密码修改后旧密码仍能用 | 没有对输入密码做同规则加密 | 比对前统一走Md5Util.md5 |
3.3 欢迎页的延迟跳转与生命周期坑
欢迎页最常见实现是Handler.postDelayed延时 1.5 秒跳转。但如果在onDestroy里忘记移除回调,用户退出后回调仍然执行,就会跳到一个已经销毁的 Activity。
if (isFinishing()) { return; } getWindow().getDecorView().postDelayed(() -> { if (isFinishing()) { return; } startActivity(new Intent(SplashActivity.this, LoginActivity.class)); finish(); }, 1500);这里用getWindow().getDecorView().postDelayed替代new Handler(),因为 View 被移除后,其消息队列的 Runnable 会随窗口一起失效,不需要手动 removeCallbacks,也能避开 Android Studio 的 Handler 泄漏警告。1.5 秒是一个体感合适的时间,太长用户会觉得卡,太短 Splash 起不到品牌展示作用。
4. Fragment 容器、商品详情与个人中心联动
首页、发布、我的这三个主界面,如果每个都做成独立布局文件倒也能跑,但“我的收藏”“我的评论”都要复用商品详情页,所以主界面必须用 Fragment 容器管理。
4.1 底部三个 Tab 用 show/hide 而不是 replace
用BottomNavigationView挂三个 Fragment 的常见做法是show/hide,不是replace。replace每次切换都会销毁上一个 Fragment,商品列表的滚动位置和筛选状态全丢。
private final List<Fragment> fragments = new ArrayList<>(); private void switchTo(int index) { Fragment target = fragments.get(index); FragmentTransaction tx = getSupportFragmentManager().beginTransaction(); for (Fragment f : fragments) { if (f.isAdded() && f != target) { tx.hide(f); } } if (target.isAdded()) { tx.show(target); } else { // container 是主布局里的 FrameLayout tx.add(R.id.container, target); } tx.commit(); }四个分类的学习、生活、体育、电子,没必要各建一个独立 Fragment。一个HomeFragment内部用横向RecyclerView渲染分类栏,下方再放一个竖向RecyclerView渲染商品列表,点击分类时只改currentCid并重新加载数据,代码量最少。
4.2 首页商品列表的异步装载与通知刷新
RecyclerView的 Adapter 数据源要用List<Goods>而不是数组,这样后续分页、排序都好扩展。列表加载放到子线程,拿到结果再切回主线程更新 UI。
private void loadGoods(final int cid) { new Thread(() -> { List<Goods> list = DBManager.getInstance(requireContext()) .queryGoodsByCategory(cid); if (getActivity() != null) { getActivity().runOnUiThread(() -> { goodsAdapter.setData(list); goodsAdapter.notifyDataSetChanged(); }); } }).start(); }很多新手在这里会犯一个错误:在子线程里直接调用adapter.notifyDataSetChanged()。RecyclerView 的刷新操作必须在主线程,否则会抛CalledFromWrongThreadException。notifyDataSetChanged()是整表刷新,商品数量小时够用;数量大了要改用DiffUtil做局部更新。
4.3 评论、收藏和个人中心列表的三条 SQL
个人中心要显示“我的闲置”“我的收藏”“我的评论”,其实就是三个查询方法。收藏和评论都需要 JOINgoods表,才能在列表页直接展示商品标题和缩略图。
// 我的闲置 public List<Goods> queryGoodsByUser(int uid) { return queryGoods("WHERE uid = ? ORDER BY created_at DESC", new String[]{String.valueOf(uid)}); } // 我的收藏:收藏表 JOIN 商品表 public List<Goods> queryFavoriteGoods(int uid) { String sql = "SELECT g.* FROM favorite f " + "JOIN goods g ON f.goods_id = g.id " + "WHERE f.uid = ? ORDER BY f.created_at DESC"; // 统一 Cursor 映射逻辑 } // 我的评论:评论表 JOIN 商品表,取商品标题 public List<Comment> queryCommentsByUser(int uid) { String sql = "SELECT c.*, g.title AS goods_title FROM comment c " + "JOIN goods g ON c.goods_id = g.id " + "WHERE c.uid = ? ORDER BY c.created_at DESC"; // 统一 Cursor 映射逻辑 }收藏按钮的点击事件是一个天然的切换开关。查询到isFavorite(uid, goodsId)为 true 时,点击后执行DELETE,否则执行INSERT。注意INSERT之前要判断是否已经收藏,否则快速点击会插入两条重复记录。
5. 发布闲置:权限适配、图片压缩与入库刷新
发布页是功能密度最高的一个页面。图片选择涉及运行时权限,图片路径涉及分区存储,图片显示涉及 Bitmap 内存,发布完成后还要让首页列表刷新。
5.1 相册选图的运行时权限适配
Android 6.0 之后危险权限需要运行时申请,Android 13(API 33)又把图片权限从READ_EXTERNAL_STORAGE拆成了READ_MEDIA_IMAGES。如果不做版本判断,在新机型上会直接崩。
private void chooseImage() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_MEDIA_IMAGES}, 100); return; } } else if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 100); return; } // 低版本兼容写法,新项目建议改成 ActivityResultLauncher startActivityForResult(new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI), 101); }onRequestPermissionsResult里要判断用户是否勾选了“不再询问”,如果拒绝后再次进入选择图片,最好的交互是跳转到系统设置页,而不是只弹 Toast。这个细节在答辩时经常被加分。
5.2 从 Uri 取路径与采样压缩
相册返回的data.getData()是 content:// Uri,不是文件路径。老机型可以通过MediaStore.Images.Media.DATA列查出来,Android 10 分区存储后该列经常返回空,更稳妥的做法是用ContentResolver.openInputStream复制到应用私有目录。
private File compressImage(Uri uri) { String realPath = getRealPathFromUri(uri); BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inJustDecodeBounds = true; BitmapFactory.decodeFile(realPath, opts); // 采样率 4,表示图片宽高各缩到原来的 1/4,内存降到约 1/16 opts.inSampleSize = 4; opts.inJustDecodeBounds = false; Bitmap bmp = BitmapFactory.decodeFile(realPath, opts); File dir = new File(getExternalFilesDir(null), "goods_image"); if (!dir.exists()) { dir.mkdirs(); } File out = new File(dir, System.currentTimeMillis() + ".jpg"); try (FileOutputStream fos = new FileOutputStream(out)) { bmp.compress(Bitmap.CompressFormat.JPEG, 80, fos); } return out; }inSampleSize = 4不是把图片压缩到原来的 1/4,而是宽和高各缩放 1/4,内存占用约原来的 1/16。手机相册一张 4000x3000 的图,直接加载到 Bitmap 会占用 48MB 内存,采样后只有 3MB。发布页的 ImageView 这种小尺寸展示场景,完全够用。
5.3 发布按钮的数据组装与列表刷新
发布按钮要做三件事:从表单收集数据、组装成Goods对象、插入 SQLite。发布完成后回到首页,首页的onResume重新调用loadGoods(currentCid)就能看到新发布的内容。
btnPublish.setOnClickListener(v -> { Goods goods = new Goods(); goods.setUserId(UserHolder.getInstance().getUser().getId()); goods.setCategoryId(currentCategoryId); goods.setTitle(etTitle.getText().toString().trim()); goods.setDetail(etDetail.getText().toString().trim()); goods.setPrice(Double.parseDouble(etPrice.getText().toString())); goods.setContact(etContact.getText().toString().trim()); goods.setImagePath(compressFile.getAbsolutePath()); goods.setStatus(0); new Thread(() -> { long id = dao.insertGoods(goods); runOnUiThread(() -> { if (id > 0) { Toast.makeText(this, "发布成功", Toast.LENGTH_SHORT).show(); finish(); } }); }).start(); });Double.parseDouble在输入框为空时会抛NumberFormatException,所以发布前要校验价格字段不能为空。如果想要发布页和首页实时联动,可以在finish()前setResult(RESULT_OK),首页在onActivityResult里刷新,比全局广播更轻量。
6. 进阶收尾:名言通知、全局退出登录与 APK 验证
最后这三个功能虽然代码量不大,但涉及通知渠道、静态广播和 APK 反编译验证,是区分“会写页面”和“理解安卓机制”的分水岭。
6.1 点一次推送一条名言:NotificationChannel 必配
8.0 之后通知必须绑定 NotificationChannel,否则通知不显示;13.0 之后还需要运行时申请POST_NOTIFICATIONS权限。
private void pushQuote(String quote) { String channelId = "quote_channel"; NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( channelId, "每日名言", NotificationManager.IMPORTANCE_DEFAULT); nm.createNotificationChannel(channel); } if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.POST_NOTIFICATIONS}, 200); return; } NotificationCompat.Builder builder = new NotificationCompat.Builder(this, channelId) .setSmallIcon(R.drawable.ic_quote) .setContentTitle("每日名言") .setContentText(quote) .setAutoCancel(true); NotificationManagerCompat.from(this).notify(1, builder.build()); }IMPORTANCE_DEFAULT会响铃并出现在状态栏,IMPORTANCE_LOW只出现在通知栏不响铃。每日名言这种轻度提醒,用IMPORTANCE_DEFAULT即可。通知渠道创建一次后,用户可以在系统设置里单独改通知行为,代码不需要重新下发。
6.2 退出登录广播与页面清理
“退出登录”需要让当前栈里所有 Activity 都退出,并清理登录态。自定义广播是最直观的实现,在任意页面调用sendBroadcast后,注册了该 Action 的接收器统一处理。
public class LogoutReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if ("com.example.secondhand.ACTION_LOGOUT".equals(intent.getAction())) { UserHolder.getInstance().clear(); Intent login = new Intent(context, LoginActivity.class); login.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); context.startActivity(login); } } }发送方就一行代码:
sendBroadcast(new Intent("com.example.secondhand.ACTION_LOGOUT"));FLAG_ACTIVITY_CLEAR_TASK会清空整个任务栈,再启动一个新的 LoginActivity,用户按返回键不会回到已退出登录的页面。注意静态注册的广播接收器在接收后启动 Activity,必须带上FLAG_ACTIVITY_NEW_TASK,否则会报Calling startActivity() from outside of an Activity context。
6.3 验收技巧:查 Janky 帧与反编译确认资源
拿到一个二手交易系统 APK 后,先不要急着打开功能,用 adb 查一下界面流畅度最有说服力:
adb install -r app-debug.apk adb shell am start -n com.example.secondhand/.ui.SplashActivity adb shell dumpsys gfxinfo com.example.secondhand | grep -E "Janky frames|Total frames"Janky frames 占比超过 10%,优先检查列表里的图片加载和主线程 SQLite 操作。dumpsys gfxinfo还能看到每一帧的耗时分布,是定位卡顿的第一手数据。
从 Android Studio 工程打包的 APK 可以直接用jadx反编译:jadx -d jadx_out release.apk。它会还原出可读的 Java 源码和 AndroidManifest,适合核对项目里是否真的包含了广播接收器、通知渠道和数据库建表语句。下载 Android Studio 和 SDK 时如果同步慢,把 Gradle 仓库换成国内镜像,Build 报tag number over 30 is not supported通常是 Build-Tools 版本太旧,先去 SDK Manager 升级 platform-tools,再检查 AGP 版本是否匹配。
本文还有配套的精品资源,点击获取