
简介在移动应用开发中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、passwordgoods首页列表、发布、我的闲置uid、cid、status、pricefavorite我的收藏uid、goods_idcomment商品详情、我的评论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 商品列表查询 SQLLEFT JOIN 与 Cursor 映射首页按分类取商品列表时需要同时显示发布者昵称这时候要 LEFT JOINuser表。用?占位符而不是字符串拼接可以防 SQL 注入也是登录查询的正确写法。public ListGoods queryGoodsByCategory(int cid) { ListGoods 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 里直接存明文密码。毕设不需要上 bcryptMD5 加盐已经比明文强一个档次。盐值固定写死在代码里生产环境则要给每个用户生成随机盐并单独存储。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 线程不允许执行网络和磁盘 IOAndroid 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.md53.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 ListFragment 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 数据源要用ListGoods而不是数组这样后续分页、排序都好扩展。列表加载放到子线程拿到结果再切回主线程更新 UI。private void loadGoods(final int cid) { new Thread(() - { ListGoods 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 ListGoods queryGoodsByUser(int uid) { return queryGoods(WHERE uid ? ORDER BY created_at DESC, new String[]{String.valueOf(uid)}); } // 我的收藏收藏表 JOIN 商品表 public ListGoods 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 ListComment 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 13API 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 framesJanky 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 版本是否匹配。本文还有配套的精品资源点击获取