简介:一份基于安卓平台的水果蔬菜销售应用设计与实现文档,适合正在开展课程设计、毕业设计或移动应用开发入门的学习者参考使用。文档从智能手机普及和移动销售应用的发展背景切入,系统梳理了选题意义、研究现状,以及经济、技术、操作三方面的可行性分析;同时围绕安卓客户端的拍照、上传、图片处理和后台信息传输等核心功能,结合智能终端的通讯、定位和摄像服务,给出了完整的设计与分析思路,也提供了论文必备的中英文摘要、目录等章节结构,既能帮助读者快速搭建销售类项目的文档框架,又能为实际开发流程提供借鉴。整个资源包为单个docx文档,压缩包约892KB,解压后可直接查看或编辑。目前已有79人学习这份文档,适合需要撰写安卓相关设计报告或开发类似果蔬销售应用的用户,尤其是时间紧凑、需要快速成文的同学。
1. 先把这套果蔬销售APP的边界看清楚
手机下单买水果这件事,现在看起来稀松平常,但放在课程设计里其实是一个很好的Android综合练习:它同时牵扯用户登录、商品列表、购物车、订单评价、拍照上传和后台管理,几乎把Android开发的主要知识点都覆盖了。这套基于Android的水果蔬菜销售APP,核心不是界面多漂亮,而是把“用户在手机上挑菜下单——后台管理员更新商品和维护订单——图片通过拍照上传到服务端”这条完整链路跑通。对正在做毕设或想从单一页面练习跳到完整项目的人来说,这个项目的价值在于:你能看到Activity、Service、SQLite、FileProvider、网络请求这些组件是怎么在一个真实业务场景里协同工作的,而不是各自孤立地写Demo。下文我会按“职责划分 → 数据层 → 登录商品订单 → 拍照上传 → 环境排错 → 优化”的顺序把整个系统拆开讲,每一步都给出可直接移植的代码和参数说明。
2. 系统拆解与核心选型:Android四大组件各自扛什么活
2.1 为什么这个系统要拆成Android客户端和Web后台两端
这个项目在构架上有一个很容易被忽略的点:客户端负责“采集与展示”,服务端负责“管理与分发”。商品类别管理、商品列表管理、用户管理、订单管理、评价管理这五块功能,全部放在Web后台,由管理员录入和维护;Android客户端只是通过HTTP接口拉取数据、展示商品列表、提交订单,并把拍照得到的图片上传。这样拆的原因很实际:商品价格和库存变化频繁,如果把数据写死在手机本地,每次改价格都要重新发版,而Web后台改数据库即可。客户端这边的本地SQLite只承担“缓存”和“离线暂存”的角色,比如弱网环境下把订单先写入本地队列,等网络恢复后再同步到服务器。
用户操作流程:登录注册 → 浏览商品列表 → 点击商品加入购物车 → 提交订单 → 拍照上传/评价 → Web后台管理订单与商品这个流程决定了你至少需要三张核心表:用户表、商品表、订单表,外加商品类别表和评价表。对课程设计而言,这样的拆法既能体现Android端的数据持久化能力,又能体现网络交互,评分上不会吃亏。
2.2 Activity、Service、BroadcastReceiver、ContentProvider的职责边界
四大组件在这个项目里的分工非常清晰。Activity负责所有可见界面:LoginActivity处理登录注册,MainActivity展示商品列表,CartActivity管理购物车,OrderActivity展示订单状态;Service负责后台传输,比如商品图片的批量上传和订单的同步推送,即使Activity退出前台,Service仍然可以继续完成数据提交;BroadcastReceiver负责监听网络状态变化,在Wi-Fi和移动数据之间切换时给出提示,避免用户在流量环境下误触发大图上传;ContentProvider在这个项目中最典型的使用场景是让相机应用把拍摄的照片写入你APP指定的URI,这涉及到Android 7.0以后的FileProvider配置,后面第四章会专门讲。
2.3 SQLite在本项目中的存储设计
SQLite是这个项目唯一的本地数据库,选它的理由很直接:Android系统内置,不需要额外引入数据库服务,零配置即可使用。在assets目录或应用首次启动时执行建表脚本,定义一个DBHelper继承SQLiteOpenHelper。表结构设计如下:
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')) ); CREATE TABLE goods ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category_id INTEGER, price REAL NOT NULL, stock INTEGER DEFAULT 0, image_url TEXT, description TEXT, FOREIGN KEY (category_id) REFERENCES category(id) ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, goods_id INTEGER NOT NULL, quantity INTEGER DEFAULT 1, total_price REAL, status INTEGER DEFAULT 0, order_time TEXT DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (goods_id) REFERENCES goods(id) ); CREATE TABLE comment ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, user_id INTEGER NOT NULL, rating INTEGER DEFAULT 5, content TEXT, comment_time TEXT DEFAULT (datetime('now')) );订单表里的status字段用整型表示状态:0代表待支付,1代表已支付待发货,2代表已发货,3代表已完成。设计成整型而不是字符串,是为了后续在Web后台做状态流转判断时可以直接用switch比较,避免每次都用equals匹配字符串,性能更好且不容易在大小写上出错。商品表的image_url字段在前端用相机拍照后保存的是本地路径,上传成功后再替换成服务器URL。
3. 从登录注册到订单评价:核心功能模块的落地实现
3.1 注册登录:SQLiteOpenHelper的完整用法
登录注册不难,但要注意一个坑:很多人喜欢把密码明文存进SQLite,这在课程设计里虽然能跑,但一旦答辩老师问你安全性,就很难解释。建议至少做一次MD5加盐处理。下面给出注册时插入用户的代码:
public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "fruit_shop.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 IF NOT EXISTS user (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "username TEXT NOT NULL UNIQUE," + "password TEXT NOT NULL," + "phone TEXT)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS user"); onCreate(db); } public long register(String username, String password) { SQLiteDatabase db = getWritableDatabase(); ContentValues values = new ContentValues(); values.put("username", username); values.put("password", MD5Util.md5(password + "fruit_salt")); return db.insert("user", null, values); } }DB_VERSION的初始值是1,当后续表结构发生变化时需要自增,并覆写onUpgrade方法执行ALTER TABLE语句,而不是直接DROP TABLE,否则用户数据会在升级时全部丢失。加盐固定字符串的做法在真实生产环境不够安全,但比明文存储强得多,而且实现成本几乎为零。注册方法的返回值是long类型,当插入失败时返回-1,用这个值判断用户名是否重复即可。
3.2 商品列表:ListView加载与Adapter的性能边界
商品列表是用户进入APP后第一个核心界面。在Eclipse + ADT那个时代的课程设计里,最常见的方案是ListView + SimpleAdapter,三五行代码就能把数据渲染出来。但SimpleAdapter存在一个明显问题:它每次getView()都会通过findViewById()重新查找控件,列表滑动时会造成卡顿,尤其在商品图片大、条目多的时候更明显。下面是优化过的ViewHolder写法:
public class GoodsAdapter extends BaseAdapter { private List<Goods> goodsList; private LayoutInflater inflater; public GoodsAdapter(Context context, List<Goods> list) { this.goodsList = list; this.inflater = LayoutInflater.from(context); } @Override public int getCount() { return goodsList == null ? 0 : goodsList.size(); } @Override public Object getItem(int position) { return goodsList.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { convertView = inflater.inflate(R.layout.item_goods, parent, false); holder = new ViewHolder(); holder.nameView = convertView.findViewById(R.id.goods_name); holder.priceView = convertView.findViewById(R.id.goods_price); holder.stockView = convertView.findViewById(R.id.goods_stock); convertView.setTag(holder); } else { holder = (ViewHolder) convertView; } Goods goods = goodsList.get(position); holder.nameView.setText(goods.getName()); holder.priceView.setText("¥" + goods.getPrice()); holder.stockView.setText("库存" + goods.getStock() + "件"); return convertView; } static class ViewHolder { TextView nameView; TextView priceView; TextView stockView; } }convertView为空时才执行inflate和findViewById,之后通过setTag把控件引用缓存起来。这种写法在Android开发里已经延续了十多年,即使现在用RecyclerView,底层复用的思路依然一致。如果项目里用的是ArrayAdapter或SimpleAdapter,可以对照这个版本理解复用机制:前者每次滚动都会重新findViewById,后者只在第一次创建View时查找一次。课程设计的数据量通常只有几十条,两种方案在性能上很难察觉差别,但答辩时能说清楚这个优化点,观感会好很多。
3.3 加入购物车与提交订单:事务机制保证数据一致
商品加入购物车后,用户点击结算会生成一条订单记录,同时扣减库存。这个操作涉及两张表的写入:orders表插入订单数据,goods表更新剩余库存。如果先插订单后更新库存时应用崩溃,就会出现“订单已生成但库存没扣减”的脏数据。解决方法是使用SQLite的事务:
public boolean placeOrder(int userId, int goodsId, int quantity, double price) { SQLiteDatabase db = dbHelper.getWritableDatabase(); boolean result = false; db.beginTransaction(); try { ContentValues orderValues = new ContentValues(); orderValues.put("user_id", userId); orderValues.put("goods_id", goodsId); orderValues.put("quantity", quantity); orderValues.put("total_price", price * quantity); orderValues.put("status", 0); db.insert("orders", null, orderValues); ContentValues goodsValues = new ContentValues(); goodsValues.put("stock", "stock - " + quantity); db.execSQL("UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ?", new Object[]{quantity, goodsId, quantity}); db.setTransactionSuccessful(); result = true; } catch (Exception e) { e.printStackTrace(); } finally { db.endTransaction(); } return result; }这里有一个经常踩的坑:更新库存用了goodsValues.put("stock", "stock - " + quantity)是不行的。ContentValues的put方法只接受具体的值,不会解析字段名,这种写法会把字符串当成新的库存值写进去。正确做法是用execSQL配合占位符直接执行SQL,上述代码里stock = stock - ?就是正确写法。同时用WHERE stock >= ?条件可以在库存不足时让本次更新影响行数为0,配合更新行数的返回值判断是否下单成功,可以做到“库存不足不下单”。
3.4 评价管理:用RatingBar接收用户打分
评价模块在需求分析里单独列了出来,实现时通常放在订单详情页或用户确认收货之后。界面用一个RatingBar接收打分(星级1-5),一个EditText接收文字评论,提交后调用与商品列表相同的方式插入comment表。有一个细节值得注意:RatingBar的numStars属性决定了显示几颗星,但用户在真机上点击时,默认的stepSize是0.5,也就是说用户可能打出3.5星这样的分数。如果业务上只允许整数评分,需要把stepSize设为1.0,同时在RatingBar.setOnRatingBarChangeListener回调里把rating转为整型再入库。这一块在测试时要格外关注,因为模拟器上点击精确度没有真机高,很容易出现评分不符合预期的情况。
4. 拍照上传与网络通信:图片数据是怎么从手机到服务器的
4.1 调用系统相机拍照并返回图片路径
水果蔬菜销售APP在原始需求里明确提到“照相、上传、图片处理”,这个功能在Android开发里属于中高难度模块,因为牵涉系统相机调用、临时文件存储、权限适配和URI共享。调用系统相机的标准写法如下:
private static final int REQUEST_IMAGE_CAPTURE = 1; private Uri photoUri; private File photoFile; private void dispatchTakePictureIntent() { Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(getPackageManager()) != null) { photoFile = createImageFile(); if (photoFile != null) { photoUri = FileProvider.getUriForFile(this, "com.example.fruitshop.fileprovider", photoFile); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE); } } } private File createImageFile() throws IOException { String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.CHINA).format(new Date()); File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); return File.createTempFile("JPEG_" + timeStamp, ".jpg", storageDir); }代码有两个关键点,都是容易被系统版本卡住的地方。第一个是FileProvider.getUriForFile,从Android 7.0(API 24)开始,应用之间传递file://协议的URI会直接抛FileUriExposedException,必须通过FileProvider生成content://协议的URI才能安全地把拍照输出路径交给相机应用。第二个是getExternalFilesDir,这个方法返回的是应用专属外部存储目录,路径形如/storage/emulated/0/Android/data/包名/files/Pictures,不需要申请存储权限就能读写,而直接使用Environment.getExternalStorageDirectory()则需要动态申请WRITE_EXTERNAL_STORAGE权限,在Android 11以上还会被分区存储限制访问。没有拿到相机的拍照结果时,onActivityResult返回的data可能为null,所以保存的photoUri必须在调用相机前就创建好。
4.2 图片压缩:控制上传流量和服务器存储压力
手机拍出来的照片通常在3MB到8MB之间,直接使用base64编码后体积会增加约33%,如果用户用移动数据流量上传,这笔流量开销会让体验非常差。常见的做法是先把照片进行质量压缩和采样压缩,再按需上传。样品压缩的核心逻辑如下:
private Bitmap getCompressedBitmap(String imagePath, int targetWidth) { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFile(imagePath, options); int sampleSize = 1; int originalWidth = options.outWidth; while (originalWidth / sampleSize > targetWidth) { sampleSize *= 2; } options.inSampleSize = sampleSize; options.inJustDecodeBounds = false; Bitmap bitmap = BitmapFactory.decodeFile(imagePath, options); ByteArrayOutputStream baos = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos); return bitmap; }inSampleSize设置为2的幂次方时,解码效率最高。targetWidth一般取1080或1440,这个尺寸已经足够覆盖大多数手机屏幕的显示需求。compress方法的第二个参数quality设为80是经验值:JPEG在80%质量下压缩率能达到70%以上,肉眼看不出明显质量损失,而继续压到60%以下时,在水果商品图这类色彩丰富、有文字标签的图片上会出现明显伪影。压缩后的图片内存占用以四字节ARGB通道计算,一张1440x1080的图片约为6MB内存,处理完要立即调用bitmap.recycle()释放(在非复用Bitmap对象时)。
4.3 HTTP上传:用Multipart协议把图片送到Web后台
Web后台接收图片的方式通常是multipart/form-data格式的POST请求,Android端不用第三方框架的话,直接用HttpURLConnection手写上传。现在已经不推荐用老旧的HttpClient(API 23以后已经从Android系统移除)。下面的代码展示了单文件上传的核心逻辑:
private void uploadImage(File imageFile, String uploadUrl) throws IOException { String boundary = "----WebKitFormBoundary" + UUID.randomUUID().toString(); HttpURLConnection connection = (HttpURLConnection) new URL(uploadUrl).openConnection(); connection.setRequestMethod("POST"); connection.setDoOutput(true); connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream()); outputStream.writeBytes("--" + boundary + "\r\n"); outputStream.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"" + imageFile.getName() + "\"\r\n"); outputStream.writeBytes("Content-Type: image/jpeg\r\n\r\n"); FileInputStream fileInputStream = new FileInputStream(imageFile); byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = fileInputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } fileInputStream.close(); outputStream.writeBytes("\r\n--" + boundary + "--\r\n"); outputStream.flush(); outputStream.close(); int responseCode = connection.getResponseCode(); if (responseCode == 200) { // 上传成功,解析服务器返回的图片URL并更新商品记录 } connection.disconnect(); }boundary是Multipart协议的分隔标记,理论上任意字符串都可以,但为了避免和二进制内容冲突,通常用一段带随机UUID的字符串。表单字段名name="file"必须和Web后台(Servlet或Spring MVC)接收参数名一致,这是Multipart协议里最容易对接失败的地方。上传大图片时,DataOutputStream的缓冲区设成8192字节比较合理,太小的缓冲区(如1024)会导致循环次数增多,太大的缓冲区(如64KB)在弱网环境下反而会让内存压力更大。
4.4 Service承上启下:弱网环境下的上传队列
当用户拍摄完多张商品图片后,如果一张一张地依次上传,用户需要一直停留在上传页面。更好的做法是把上传任务丢给Service在后台执行,用户可以直接返回到商品列表继续浏览。Service的启动方式有两种:startService()适合这种“启动后不管”的耗时任务,bindService()适合需要和Activity实时交互上传进度的场景。本项目的做法是:在用户点击“发布商品”时,把待上传的图片路径放入Intent,通过startService()传递给UploadService。Service内部维护一个HandlerThread循环取任务,逐个上传。上传失败的任务不直接丢弃,而是重试三次后把状态标记为失败并写入本地数据库,等用户下次进入发布页时提示“有未完成的上传任务”。
Service本身的onStartCommand返回值是有讲究的,如果返回START_STICKY,当Service被系统在内存紧张时杀死,系统会尝试重新创建Service,适合上传这种需要保底执行的任务;如果返回START_NOT_STICKY,被杀掉后就不会重启,适合定时同步这类“错过就错过”的任务。这里还需要在AndroidManifest.xml中给UploadService声明权限:
<service android:name=".service.UploadService" android:exported="false" />android:exported="false"是必须加上的,从Android 12(API 31)开始,如果Service使用了intent-filter但没有显式声明exported,应用安装时会直接报错。所有Service的onStartCommand都要注意不能把耗时任务直接写在主线程里,上面的HandlerThread就是为了把上传逻辑从主线程中剥离开。
5. 开发环境搭建与联调排错:从Eclipse工程到Android Studio的迁移路上
5.1 环境搭建:JDK、Android SDK和模拟器
这个课程设计原始文档写的是Eclipse + ADT + Android 4.0时代的产物,但现在实际动手建议直接用Android Studio。搭建时要注意JDK版本和AGP(Android Gradle Plugin)版本匹配:AGP 8.x要求JDK 17,AGP 7.x兼容JDK 11。Android SDK可以在SDK Manager里勾选安装,国内环境建议配置阿里云镜像加速下载,否则在google()仓库拉依赖时经常超时。
build.gradle 中常见仓库配置: maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' }模拟器推荐使用Android 13或Android 14系统镜像。课程设计项目里如果用了高版本的API特性,在低版本模拟器上运行会直接崩溃;反过来,targetSdkVersion很高但minSdkVersion很低时,需要注意权限兼容。
5.2 从Eclipse工程迁移到Android Studio的两个关键步骤
如果你下载到的源码是Eclipse工程,目录里会有.classpath和.project文件。Android Studio打开时选择Import Project,它会自动生成Gradle配置,但这个自动转换并不总是可靠。最常遇到的问题有两个:第一个是libs目录下的jar包没有自动引用进Gradle依赖;第二个是原有项目依赖的Android 4.0 API在新SDK里面被标记@Deprecated,编译时会报警告,不影响运行但影响编译日志的干净度。迁移完成后建议手动在app/build.gradle中检查一遍dependencies闭包,把缺失的本地jar包补上:
dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.squareup.okhttp3:okhttp:4.12.0' }implementation fileTree(dir: 'libs', include: ['*.jar'])这行是Android Studio新建工程默认就有的,但Eclipse导入的工程有时会漏掉,导致编译时报“程序包不存在”错误。另外老项目可能用的是android.support包,在新版本AS里需要改成androidx,这需要把android:name引用的所有Activity基类和RecyclerView等组件全部替换,工作量不小。
5.3 adb调试:查看日志和通过WiFi连接真机
联调阶段最常用的工具是adb。直接跑模拟器时,连接是自动建立的;连真机时需要先在开发者选项里打开USB调试,然后在终端执行以下命令:
adb devices adb install -r app-debug.apk adb logcat | grep "AndroidRuntime"adb devices确认设备在线时,注意状态的三种可能:device(正常)、unauthorized(手机端未允许授权)、offline(驱动问题或USB接口不稳定)。install -r后面的-r参数是强制重装,作用是在覆盖安装时保留已有的应用数据,调试时你会发现如果不加这个参数,每次安装后登录状态都要重新输入。logcat加上grep过滤可以只查看崩溃堆栈,但在Android 8.0以上的系统里,默认日志区被主要分配给了Main缓冲区,而活动Activity的输出通常都在对应进程的缓冲区中。如果日志抓不到,先用adb logcat -c清空一次,复现崩溃后再看。
5.4 高频踩坑:FileProvider冲突、权限拒绝、SQLite锁
把课程设计里遇到最多的三类报错单独列出来,每个都可以讲清楚修复方式。
| 报错现象 | 根本原因 | 解决方案 |
|---|---|---|
FileUriExposedException | API 24以上仍使用file://协议共享URI | 改用FileProvider.getUriForFile生成content:// |
SecurityException: Permission Denial | 相机应用无法读取你提供的URI | 在onCreate中给相机应用临时授予权限 |
SQLiteException: database is locked | 多线程同时写数据库,或close()时机不对 | 写入操作全部放到同一线程执行,或用事务包裹 |
权限问题要展开细说:通过FileProvider.getUriForFile拿到URI后,还需要在Intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)中给相机临时授予URI读取权限。如果不加这个flag,在小米、OPPO等对权限管控严格的机型上,相机应用会闪退或者返回data为空。SQLite锁定问题的本质是SQLite只允许一个写者。如果Service在后台写入上传状态,同时Activity也在写入订单数据,应用没有加锁的话就有概率报database is locked。规避方案是定义一个全局唯一的SQLiteDatabase实例,所有写操作都通过该实例串行执行,或者用db.beginTransaction()把涉及多次写操作的事务包起来,顺带还能利用事务的回滚免去手动清理脏数据的烦恼。加上try-finally确保endTransaction()一定会被调用,避免事务未关闭导致锁死。
6. 商品列表的流畅度进阶:从SimpleAdapter到RecyclerView的改造
最后聊一个实操性很强的优化方向,也是这个水果销售APP里最值得深挖的性能瓶颈。按照原文档设计方案,客户端商品列表用ListView + SimpleAdapter就能实现。但如果你想让项目在答辩时有更多亮点,或者手里的手机在加载带图片的商品列表时明显掉帧,把它换成RecyclerView是最直接有效的方案。改造的核心包括三个部分:
第一,布局方式更灵活。ListView只能纵向滚动,而RecyclerView通过LayoutManager可以自由切换为垂直列表、水平列表和网格布局。针对水果蔬菜这种带图片的列表,用GridLayoutManager网格布局会比单列列表更有展示效果,一屏能看到更多商品。要实现网格布局只需一行代码:
recyclerView.setLayoutManager(new GridLayoutManager(context, 2));第二,增加条目动画。RecyclerView内置了ItemAnimator,默认情况下执行notifyItemInserted、notifyItemRemoved时会有位移动画,而ListView没有这个能力。用户在购物车里删除一件商品时,用notifyItemRemoved(position)而不是notifyDataSetChanged(),不仅视觉上更顺滑,性能也更好,因为后者会强制刷新整个列表。
第三,图片加载尽量用库。商品图片如果已经从Web后台加载到本地,仍然建议引入Glide来加载:
implementation 'com.github.bumptech.glide:glide:4.16.0'Glide.with(context) .load(goods.getImageUrl()) .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_error) .override(300, 300) .into(holder.imageView);override(300, 300)的作用是在图片采样解码阶段就限制尺寸,300x300的缩略图展示已经完全足够,但内存占用比原图少了一个数量级。Glide内部还维护了三级缓存和处理线程池,比自己写在AsyncTask里加载网络图要稳定得多。改造完成后可以做一个很简单的验证:用开发者模式的“调试GPU过度绘制”功能查看列表页,如果色块从红色变成绿色,说明过度绘制明显下降。也可以用adb shell dumpsys gfxinfo检查列表滚动的帧时间,正常情况下其Janky frames占比应低于5%。这两个数据是答辩时很有说服力的实测论据。
本文还有配套的精品资源,点击获取