简介:一份基于Android Studio开发的校园二手交易系统完整App源码,面向毕业设计学生与Android初级开发者,可用于学习完整的移动端业务实现流程。项目以Java为主,辅以XML布局与Gradle构建脚本,zip压缩包共186个文件,约18.67MB,包含52个Java源码文件、57个XML配置与布局文件、24个PNG及多张WebP/JPG图片资源,另有Gradle配置文件、Git版本库文件等,目录结构清晰。当前已有473人学习下载。资源涵盖用户注册登录、商品发布浏览、购物车、订单交易、评价反馈、消息通知等典型模块,展示了RecyclerView列表、Material Design界面、支付SDK接入等常见技术点的落地方式。通过阅读和运行该项目,可以掌握Android应用从项目构建到功能实现的关键步骤,理解前后端数据交互与业务逻辑组织,为独立开发完整App打下基础。
1. 校园二手交易App,毕设最稳的一条原生路线
离答辩还有两周时,你会接触到大量这类“毕业设计源码.zip”。校园二手交易系统的价值在于:它是一个典型的业务闭环,覆盖用户体系、内容发布、列表检索和交易状态流转,几乎能把你大学四年的Android知识点串起来。用Android Studio做这个App,路线很成熟:原生Java/Kotlin写客户端,SQLite或轻量HTTP接口做数据层,再加图片处理和本地缓存,就能形成一份能演示、能讲原理、能扩展的毕业设计。适合直接用这份源码改造成自己的项目,也适合从零复刻一个。进度紧张时要优先跑通“注册—发布—搜索—下单—改状态”这条主链路,屏幕亮起来,老师能看到东西在动,比讲一堆概念都管用。
2. 技术栈选型与工程骨架:为什么原生路线更好过答辩
先回答一个躲不开的问题:老师问“为什么不用H5套壳、为什么不用Flutter”时,你怎么接。校园二手交易App拿到手之后,你先要认清它的定位——它不是要你证明跨平台能力,而是要你证明会开发原生Android应用。原生路线的学生版回答是:需要调用相机相册、做图片压缩、在弱网下维护本地缓存,这些场景原生API最直接。而且用Android Studio直接跑,调试面板、布局探测器都不用额外配置,真机连上就能看实时日志。
我一般会把项目拆成三层:UI层用Activity加Fragment管理页面,列表统一交给RecyclerView;业务层写普通的Java或Kotlin类,不引入MVP框架,因为答辩追问“Presenter为什么存在”比追问“数据怎么存”更难答满三分钟;数据层用SQLiteOpenHelper直接建表,或者用OkHttp请求后端JSON。三层之间靠回调或LiveData传结果,只要不让Activity里堆几百行代码,结构上就够站得住了。
2.1 四张表的设计:用户、商品、订单、收藏
数据结构先定下来。不管客户端后续怎么改,这四张表是校园二手交易的最小集合:用户表存账号和身份信息,商品表存发布内容与卖家冗余信息,订单表记录谁买谁卖以及状态,收藏表做“想买先关注”。冗余字段上我建议商品表直接存seller_nickname,查询列表时少一次关联,演示更流畅。
下面这是SQLiteOpenHelper的onCreate写法,也是你拿到“校园二手交易系统源码.zip”后最先该检查的地方:
public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "campus_db.db"; private static final int DB_VERSION = 1; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE user (" + "user_id INTEGER PRIMARY KEY AUTOINCREMENT," + "phone TEXT UNIQUE NOT NULL," + "nickname TEXT NOT NULL," + "password_hash TEXT NOT NULL," + "avatar TEXT," + "create_time INTEGER NOT NULL)"); db.execSQL("CREATE TABLE goods (" + "goods_id INTEGER PRIMARY KEY AUTOINCREMENT," + "title TEXT NOT NULL," + "description TEXT," + "price REAL NOT NULL," + "original_price REAL," + "image_path TEXT," + "status INTEGER DEFAULT 0," + "category TEXT," + "seller_id INTEGER NOT NULL," + "seller_nickname TEXT," + "create_time INTEGER NOT NULL)"); db.execSQL("CREATE TABLE `order` (" + "order_id INTEGER PRIMARY KEY AUTOINCREMENT," + "goods_id INTEGER NOT NULL," + "goods_title TEXT," + "buyer_id INTEGER NOT NULL," + "seller_id INTEGER NOT NULL," + "price REAL NOT NULL," + "status INTEGER DEFAULT 0," + "create_time INTEGER NOT NULL," + "update_time INTEGER NOT NULL)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 每个版本一个if块,只做增量修改 } }几个关键点:create_time我全部存System.currentTimeMillis()的毫秒值,排序和比较都比字符串稳;order表名是SQLite保留字,所以要加反引号,这是很多人第一次跑崩的细节;价格用REAL而不是INTEGER,因为二手书、自行车这类商品经常出现“18.5元”这种定价。有了这四张表,功能边界就出来了:注册登录操作user,发布商品写goods,交易流程改order,收藏是favorite。后面的代码都围绕这几张表展开,不会散。
2.2 工程配置:Gradle依赖和SDK版本一次调对
源码包解压后最先翻车的地方几乎都在Gradle。打开校园二手交易源码工程的第一步,是打开build.gradle(项目级和app模块级各一个),确认AGP版本和SDK版本。你不用追求最新,compileSdk 34、minSdk 23这一档在目前的Android Studio 2023版上非常成熟。老工程如果弹出“Migrate to AGP”的提示,先不要点,迁移会顺手把gradle-wrapper.properties也改了,改完经常拉不到对应插件。
android { namespace "com.example.campussecondhand" compileSdk 34 defaultConfig { applicationId "com.example.campussecondhand" minSdk 23 targetSdk 34 versionCode 1 versionName "1.0" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.github.bumptech.glide:glide:4.16.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' }minSdk 23意味着你基本不需要在代码里动态申请打电话、定位这类敏感权限;targetSdk 34对Android 14也能平稳安装。OkHttp用来对接后端接口,Glide负责图片加载,这两个库在源码包里几乎八成概率会出现,版本也足够稳定。如果你打开别人的工程一直编译失败,先看gradle.properties里org.gradle.jvmargs是否设置了内存上限,把-Xmx2048m写进去,能减少很多“编译到一半Android Studio卡死”的玄学问题。
2.3 为什么这张表结构能扛住扩展
表结构定成上面这样之后,有三个扩展点会很舒服:商品表加“上下架时间”只需要在onUpgrade里ALTER TABLE ADD COLUMN;订单表要加“交易备注”也不影响现有查询;收藏表后续加“收藏分组”同样是小改动。这正是毕业设计答辩时能说的点。别在一开始就把表设计成“商品表里直接存订单状态”,这样看起来简单,但两个买家同时下单时会互相覆盖,老师在数据库课上学过的范式的第一课就会拿来说事。保持goods和order分开,交易里只通过goods_id关联,状态同步用事务控制,这部分到第4章展开。
3. 把核心闭环跑通:注册登录、发布商品与列表分页
一个能站住五分钟演示的校园二手交易App,核心不是界面多漂亮,而是三条链路顺畅:新用户注册后能登录、登录后能发布商品、首页能看到商品列表并能下拉刷新。这三步跑通,答辩最怕的“死在第一屏”就不会出现。下面按这三条链路把代码路径说清楚。
3.1 注册与登录:哈希存储与登录态
先写UserDao。注册时先查phone是否重复,再插入user表;密码不存明文,用MD5加盐或SHA-256做哈希。哈希不是万能的,但它能避免你在演示时把数据库文件发给老师看,结果密码全明文这种尴尬。
public class UserDao { private SQLiteDatabase db; public UserDao(DBHelper helper) { this.db = helper.getWritableDatabase(); } // 返回 user_id,-1 表示手机号已存在 public long register(String phone, String nickname, String password) { if (queryByPhone(phone) != null) { return -1; } ContentValues values = new ContentValues(); values.put("phone", phone); values.put("nickname", nickname); values.put("password_hash", MD5Util.md5WithSalt(password)); values.put("create_time", System.currentTimeMillis()); return db.insert("user", null, values); } public User queryByPhone(String phone) { Cursor cursor = db.rawQuery( "SELECT user_id, nickname FROM user WHERE phone = ?", new String[]{phone}); if (cursor.moveToFirst()) { User user = new User(); user.setUserId(cursor.getLong(0)); user.setNickname(cursor.getString(1)); cursor.close(); return user; } cursor.close(); return null; } public boolean checkPassword(String phone, String password) { Cursor cursor = db.rawQuery( "SELECT password_hash FROM user WHERE phone = ?", new String[]{phone}); if (cursor.moveToFirst()) { String hash = cursor.getString(0); cursor.close(); return MD5Util.md5WithSalt(password).equals(hash); } cursor.close(); return false; } }登录成功后,把user_id和nickname写进SharedPreferences,后续发布商品、下单时从本地读取,避免每个页面都重新查一次数据库。注意rawQuery的?占位符顺序要与SQL里的字段一一对应,否则查出来的数据会错位,这种错位看日志很难发现,属于最常见的一类静默bug。
如果你手里的源码不是本地SQLite而是OkHttp请求后端,思路完全一样:把db.insert换成接口请求,把SharedPreferences换成后端返回的token。毕设量级下本地库的好处是不需要额外部署服务端,演示断网也能跑注册登录。
3.2 发布商品:图片压缩比上传接口更重要
发布页面一般长这样:标题、描述、分类、价格、原价、图片选择,然后点提交。校园二手场景里教科书、宿舍小电器、自行车这些商品的图片多为手机原图,一张照片动辄3MB以上。如果直接传给后端或者存本地,列表页加载必然卡顿,更严重点会在扫描时直接OOM。所以发布链路的第一步是压缩。
public static File compressImage(Context context, Uri uri, int maxWidth, int maxHeight, int quality) { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; InputStream in = context.getContentResolver().openInputStream(uri); BitmapFactory.decodeStream(in, null, options); if (in != null) in.close(); int sampleSize = 1; while (options.outWidth / sampleSize > maxWidth || options.outHeight / sampleSize > maxHeight) { sampleSize *= 2; } options.inJustDecodeBounds = false; options.inSampleSize = sampleSize; in = context.getContentResolver().openInputStream(uri); Bitmap bitmap = BitmapFactory.decodeStream(in, null, options); if (in != null) in.close(); File outFile = new File(context.getExternalFilesDir("images"), System.currentTimeMillis() + ".jpg"); FileOutputStream fos = new FileOutputStream(outFile); bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos); fos.close(); return outFile; }这段代码的留意点有三个。inSampleSize采样系数必须是2的幂,1280x960的图配合sampleSize=2会变成640x480,加载内存只是原来的四分之一;inJustDecodeBounds先读边界再解码,是避免OOM的关键;quality取50到70就够,再高对手机屏幕看不出来,却会让缓存文件大一圈。压缩完的文件放进getExternalFilesDir,这是应用专属目录,卸载后自动清理,不需要在Manifest里申请存储权限。
商品信息随后写入goods表,price存入时用Double.parseDouble做一次转换,先把用户输入字符串里的异常值挡住。发布成功后立刻把页面finish掉并刷新列表,用户在演示时看到“发布完回到首页就有新商品”,这个正反馈比任何UI动画都打动人。
3.3 商品列表:分页参数这样设计
列表页是数据量最容易失控的地方。如果一次性SELECT全部商品,等到数据超过一百条时,主页就会明显卡。分页参数我一般固定为page从1开始、pageSize取12,下拉刷新时page重置为1,上拉加载时page加1,返回条数小于pageSize就认为没有下一页。
public List<Goods> queryGoods(int page, int pageSize) { List<Goods> goodsList = new ArrayList<>(); String sql = "SELECT goods_id, title, price, image_path, seller_nickname, status " + "FROM goods ORDER BY create_time DESC LIMIT ? OFFSET ?"; Cursor cursor = db.rawQuery(sql, new String[]{ String.valueOf(pageSize), String.valueOf((page - 1) * pageSize) }); while (cursor.moveToNext()) { Goods goods = new Goods(); goods.setGoodsId(cursor.getLong(0)); goods.setTitle(cursor.getString(1)); goods.setPrice(cursor.getDouble(2)); goods.setImagePath(cursor.getString(3)); goods.setSellerNickname(cursor.getString(4)); goods.setStatus(cursor.getInt(5)); goodsList.add(goods); } cursor.close(); return goodsList; }OFFSET偏移的计算逻辑是(page-1)*pageSize,page=1时偏移0,page=2时偏移12。ORDER BY create_time DESC让新发布的商品排前面,符合校园二手“谁新谁热门”的使用直觉。拿到数据后丢给RecyclerView的Adapter,图片绑定用Glide一行搞定:
Glide.with(imageView.getContext()) .load(goods.getImagePath()) .placeholder(R.drawable.img_placeholder) .centerCrop() .into(imageView);centerCrop会等比裁剪填满卡片,避免不同尺寸图片把列表撑得高低不齐。到这里,“首页能看、刷新能换、点击能进详情”的最小列表闭环就完成了。
4. 订单状态机与留言:交易模块比列表更吃设计
列表页只要数据对就能过,交易流程才是答辩老师最容易深挖的地方。校园二手交易和电商下单有一个重要区别:没有在线支付,交易靠线下见面完成。因此订单状态不能简单用“0未支付、1已支付”,而是要覆盖“发布—被预订—卖出去/取消”这条线下链路。这部分设计直接决定你被追问时能否把数据一致性讲清楚。
4.1 订单状态机:用事务保住商品表和订单表
状态我按这个规则分:goods.status和order.status都存同一套数值,0表示未成交、1表示已预订、2表示已售出、3表示已关闭。买家点“我要买”时,商品从0变1,同时生成一条订单;卖家在“我卖出的”页面点确认成交,订单从1变2,商品也从1变2。这套状态机的好处是列表页只需要读goods.status就能判断“还在卖”还是“已售出”,不需要联表查订单。
// 返回新的状态,返回-1表示非法流转 public static int resolveStatus(int currentStatus, int action) { switch (currentStatus) { case 0: // 未成交 return action == 1 ? 1 : -1; // 买家下单 -> 已预订 case 1: // 已预订 return action == 2 ? 2 : 3; // 卖家确认 -> 已售出;关闭订单 -> 已关闭 case 2: // 已售出 case 3: // 已关闭 default: return -1; } }注意action的语义要固定:1是买家下单,2是卖家确认,3是关闭订单。千万别写成“按钮点一下就加一”,否则从已售出再按一次会变出第4个状态,数据库里出现脏数据。业务代码里调用put(“status”, resolveStatus(...))之前,先读一次当前状态,避免并发修改。
商品表和订单表的状态要同步更新,这一步必须在同一个事务里做。直接调两次db.update也可以,但中途若抛异常就会留下“订单已成交、商品还在卖”的错乱数据。
db.beginTransaction(); try { ContentValues goodsValues = new ContentValues(); goodsValues.put("status", OrderStatus.SOLD); db.update("goods", goodsValues, "goods_id = ?", new String[]{String.valueOf(goodsId)}); ContentValues orderValues = new ContentValues(); orderValues.put("status", OrderStatus.SOLD); orderValues.put("update_time", System.currentTimeMillis()); db.update("`order`", orderValues, "order_id = ?", new String[]{String.valueOf(orderId)}); db.setTransactionSuccessful(); } finally { db.endTransaction(); }setTransactionSuccessful标记成功,finally里统一endTransaction。这个写法不需要额外catch,因为异常抛出后endTransaction会自动回滚。答辩时被问到“怎么保证两张表一致”,直接把这段讲出来,比背概念有效得多。
4.2 “我卖出的”与“我买到的”:同一张表两个视角
订单表同时存buyer_id和seller_id,这两个字段让个人交易页面非常简单:卖家看订单,查order表里seller_id等于当前用户;买家看订单,查buyer_id等于当前用户。同一个OrderDao,一个参数区分视角。
public List<Order> queryOrders(long userId, boolean asSeller) { String sql = "SELECT order_id, goods_title, price, status, create_time " + "FROM `order` WHERE " + (asSeller ? "seller_id" : "buyer_id") + " = ? " + "ORDER BY update_time DESC"; Cursor cursor = db.rawQuery(sql, new String[]{String.valueOf(userId)}); // 组装List后返回 }注意这里拼接的是固定字符串 seller_id 或 buyer_id,不是用户输入,所以不会被注入。UI上卖家和买家的按钮文字也不一样,卖家看到“确认成交”“关闭订单”,买家看到“取消订单”。状态文字在Adapter里switch一次:0未成交、1等待卖家确认、2交易完成、3已关闭。同一套数值,展示层做一层映射就够了,不要在每个页面复制粘贴状态判断逻辑。
4.3 留言记录:不用SDK的轻量“聊天”
线下交易需要买卖双方约时间地点。真要做即时通讯就超纲了,毕设里更实际的是留言记录或者简单聊天列表。聊天本质是消息流水表:sender_id、receiver_id、content、create_time,按order_id关联到某笔交易。实现上不需要推送SDK,用轮询就够了。
// 每5秒拉取一次与我相关的新消息 private final Handler handler = new Handler(Looper.getMainLooper()); private Runnable pollTask = new Runnable() { @Override public void run() { if (isFinishing()) return; loadNewMessages(); handler.postDelayed(this, 5000); } };轮询频率建议5到8秒一次,太频繁会让数据库线程一直被读,页面滑动反而不顺。Handler的postDelayed在页面销毁时必须removeCallbacks,否则Activity退出后任务还在执行,这就是你偶尔在Logcat里看到“Activity leaked”的罪魁祸首。演示时可以主动说一句“这里用轮询而不是推送,是因为不需要申请厂商推送通道,也没有额外服务成本”,老师一般不会在这个点上纠缠。
4.4 搜索与筛选:用参数化查询挡住SQL注入
交易环节的最后一块是搜索入口。搜索看起来简单,但直接把用户输入拼进SQL字符串的写法,老师看到会直接问“有什么安全问题”。所以这里必须用参数化查询,而不是字符串拼接。
public List<Goods> searchGoods(String keyword, String category, int page, int pageSize) { StringBuilder sql = new StringBuilder( "SELECT goods_id, title, price, image_path, seller_nickname, status " + "FROM goods WHERE status = 0 AND (title LIKE ? OR description LIKE ?) "); List<String> args = new ArrayList<>(); String like = "%" + keyword + "%"; args.add(like); args.add(like); if (category != null && !category.isEmpty()) { sql.append("AND category = ? "); args.add(category); } sql.append("ORDER BY create_time DESC LIMIT ? OFFSET ?"); args.add(String.valueOf(pageSize)); args.add(String.valueOf((page - 1) * pageSize)); Cursor cursor = db.rawQuery(sql.toString(), args.toArray(new String[0])); // ... }?占位符会让SQLite把输入当纯数据对待,而不是可执行代码。keyword里带引号、百分号都不会破坏查询结构。筛选默认加status = 0,用户搜出来的都是未成交商品,这正好和4.1的状态机呼应。分类参数可选,为空时不要拼进SQL,避免查询条件变成一长串空格和问号。
5. 常见问题排查:从Gradle同步到真机闪退的五个坑
源码能正常编译,和能在你的Android Studio版本上编译,是两件事。下面这五个问题是校园二手交易App源码里最常见、也是搜索引擎里被问得最多的,按现象、原因、解决的顺序写,你遇到时可以照着核对。
5.1 Gradle同步失败:Could not determine the dependencies of task
现象:Gradle同步进度条卡住,最后报“Could not determine the dependencies of task ‘:app:compileDebugJavaWithJavac’”,后面跟着一串“Could not resolve”。原因通常有三个:依赖仓库访问不稳定、AGP版本和Android Studio版本不匹配、之前同步中断留下缓存。解决方法是先固定仓库源,在settings.gradle的pluginManagement和dependencyResolutionManagement里加上国内镜像:
pluginManagement { repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } mavenCentral() google() } }加完镜像后执行File > Sync Project with Gradle Files。如果仍然失败,检查gradle-wrapper.properties里的distributionUrl,把Gradle版本降到当前Android Studio默认适配的版本,比如AGP 8.2对应Gradle 8.2那一档。这是踩过一次就出经验的地方,不要每次都在依赖版本里找原因。
5.2 真机请求接口一直失败:网络权限与明文流量
现象:模拟器运行正常,换到真机后登录按钮一直转圈,Logcat没有任何业务报错。原因有两个:AndroidManifest里没加INTERNET权限,或者targetSdk 28以上默认禁止明文HTTP。后者最常见,因为校园二手项目用本地后端调试时基本都是http://192.168.x.x这种地址。
<uses-permission android:name="android.permission.INTERNET" /> <application android:usesCleartextTraffic="true" ...> </application>如果你的后端是HTTPS,不需要usesCleartextTraffic这行;如果是本地局域网测试,这行就是后悔药。更规范的做法是把网络安全配置写进res/xml/network_security_config.xml,只对特定域名允许明文,但毕设阶段开全局省事,老师问起时知道区别就行。
5.3 改过数据库字段后App闪退:onUpgrade名存实亡
现象:原来跑得好好的App,加了一个字段后启动就崩,Toast也不弹,直接闪退到桌面。原因多数是改了表结构,但DATABASE_VERSION没加1,或者onUpgrade里只写了DROP TABLE导致数据全没。解决时记住一条规则:改结构必须让版本号变大,并写增量迁移。
private static final int DB_VERSION = 2; @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE goods ADD COLUMN status INTEGER DEFAULT 0"); } if (oldVersion < 3) { db.execSQL("CREATE TABLE IF NOT EXISTS favorite (" + "favorite_id INTEGER PRIMARY KEY AUTOINCREMENT," + "user_id INTEGER NOT NULL," + "goods_id INTEGER NOT NULL)"); } }每个版本一个if块,是断点下载式的迁移逻辑,让老用户从任意旧版本都能走到最新。答辩演示当天,保险做法是把App卸载重装一次,确保数据库是从零创建的,这时候onCreate里的建表语句才是最重要的。
5.4 加载图片闪退:Bitmap OOM不只是内存不足
现象:列表往上翻到某几张图片时App卡死,错误信息里出现OutOfMemoryError和BitmapFactory。原因不是手机内存小,而是图片分辨率过大。相机原图动辄4000x3000,ARGB_8888格式下每个像素占4字节,一张图解码出来接近50MB,而早期Android设备heap只有几十MB,不崩才怪。
解决方法一句话:解码前用inSampleSize采样。前面3.2已经给过压缩代码,这里补充一点,如果你不处理原图,直接让Glide在加载时加override也能把内存压下来:
Glide.with(imageView.getContext()) .load(goods.getImagePath()) .override(480, 480) .centerCrop() .into(imageView);override不会改变文件本身,只控制解码到内存里的尺寸。原图该多大还多大,上传和列表显示互不影响。列表缩略图不要直接用原图路径加载,这一点在拿到任何第三方图片源码时都适用。
5.5 打包安装失败:签名与minSdk不一致
现象:AS里点Run一切正常,用Build > Build APK生成的release包发到别人手机上,提示“应用未安装”。原因通常是release包没有签名,或者安装手机的系统版本低于minSdk。解决是走Build > Generate Signed Bundle or APK,创建一个自己的jks签名文件,勾选release。想在gradle里自动化,就把签名配置写进buildTypes.release:
buildTypes { release { signingConfig signingConfigs.release minifyEnabled false } }signingConfigs.release里的storeFile指向你生成的.jks文件,密码用环境变量或本地配置文件引用,别直接写死提交。平时给同学演示用debug包就行,debug包自带调试签名,系统默认允许安装。
6. 答辩前一夜:演示数据脚本、离线缓存与三分钟演示路线
功能都跑通后,真正让作品拉开差距的是数据和演示节奏。我见过太多人拿着空列表去答辩,老师一进页面问“你的数据呢”,场面会非常干。我的办法是给项目里加一个seedData(),在首次启动或点击隐藏按钮时批量生成有真实感的校园二手商品。
6.1 一键生成带真实感的演示数据
public void seedData() { String[] titles = {"高数第七版(有笔记)", "九成新山地车", "LED护眼台灯", "考研英语真题", "机械键盘青轴", "宿舍小冰箱"}; double[] prices = {25.0, 260.0, 45.5, 30.0, 180.0, 150.0}; for (int i = 0; i < titles.length; i++) { ContentValues values = new ContentValues(); values.put("title", titles[i]); values.put("description", "自用九成新,校内可面交,价格可小刀。"); values.put("price", prices[i]); values.put("original_price", prices[i] * 1.5); values.put("status", 0); values.put("seller_id", 1); values.put("seller_nickname", "测试同学"); values.put("category", "学习/生活"); values.put("create_time", System.currentTimeMillis() - i * 3600_000L); db.insert("goods", null, values); } }create_time错开一小时,列表页的按时间倒序才看得到层级变化。这些数据全部走本地SQLite,不掉线不穿帮。价格覆盖25元教材到260元自行车,正好展示不同价位商品在列表卡片上的排版效果。别忘了把seedData放在一个不容易被普通用户触发的入口,比如“关于页面连点五次版本号”,免得演示时误触又生成一批重复数据。
6.2 离线缓存和三分钟演示顺序
另一个加分项是离线缓存:首次进列表页时把查询结果写入一张cache表或序列化到SharedPreferences,再次打开先读缓存再刷新最新数据。实现上不过是把queryGoods的结果在Adapter填充前多走一层判断,配合SwipeRefreshLayout的onRefresh更新缓存时机即可。答辩时主动说一句“这个设计让弱网环境下也能看到历史商品”,好感度会明显上去。
演示顺序建议这样走:先注册一个新账号,展示手机号查重校验;再发布一件商品,现场从相册选图并压缩;回到首页搜索刚发布的标题;切换成另一个买家账号下单;最后切回卖家账号确认成交,让老师看到列表页商品状态从“在售”变成“已售出”。整个过程控制在三分钟左右,不要点“加载更多”,因为分页参数在投影上反而讲不清,不值得为一次加载去解释OFFSET。
我自己最后的习惯,是打包前把App完整跑一遍“注册—发布—搜索—下单—确认成交”,再清空Logcat重新来一次。这条链路一旦中间断掉,优先回第5章那五个地方核对。希望帮到你。
本文还有配套的精品资源,点击获取