
1. 校园零食配送这个APP到底解决了什么痛点我一直觉得做校园类APP最忌讳的就是凭空想需求。你得先搞清楚校园里买零食这件事到底和校外买零食有什么本质区别。我自己在做这款基于Android的校园零食配送APP之前先在学校里蹲了几天观察宿舍楼下的外卖柜、超市门口的排队情况还问了一圈学弟学妹最后总结出了三个核心痛点。第一个痛点是时间高度集中。晚上九点到十一点是零食需求爆发期宿舍区到超市步行来回要十几分钟赶上排队就得二十分钟起步。第二个痛点是配送距离极短但频次极高校园内最远配送距离也就一两公里但单量集中在几栋宿舍楼这种场景和美团、饿了么那种全城配送完全不是一回事。第三个痛点是价格敏感学生群体对配送费的接受度极低超过两块钱就会劝退一批人。这三个痛点决定了APP的功能设计方向必须支持集中下单、短距离快速配送、配送费尽可能低甚至免费。所以我在需求分析阶段就把核心功能定成了四块商品浏览、购物车管理、下单支付、订单跟踪。后台这边考虑到校园场景的封闭性我没有去做复杂的商家入驻而是做成了平台自营模式——一个后台统一管理库存和接单配送员角色则由管理员在后台指派。这个定位非常关键。我做的是Android原生应用目标用户是本校学生目标设备是学生手上的安卓手机所以机型适配和流畅度优先级很高。技术栈上我选择Java而非Kotlin不是因为Kotlin不好而是Java的资料多、踩坑容易查对校园项目来说稳定压倒一切。后面会详细讲为什么这么选。2. 技术选型为什么是原生Android Java 轻量后端2.1 原生开发比跨平台方案更适合这个项目很多同学一上来就纠结用Flutter还是React Native我的看法是如果这个项目的核心诉求是完整走一遍Android开发流程那原生Android就是最合适的答案。原因有三个。第一校园配送APP的功能深度不高但系统交互很重。你要处理通知栏推送、前台服务定位、文件存储、权限管理等这些原生API在跨平台框架里虽然也能用但每碰一个就要写平台通道调试成本翻倍。第二性能感知在低端机上非常明显。学生的安卓机有不少是千元机用Flutter的渲染引擎跑复杂列表帧率稳定性不如原生RecyclerView来的扎实。第三项目要能拿得出手。面试或答辩时你讲我用原生API解决了XX兼容性问题比讲我用跨平台框架调了个插件要有说服力得多。Java和Kotlin之间我选了Java原因其实很现实校园项目的周期通常只有三到四个月Java的参考资料量是Kotlin的好几倍遇到问题搜一下基本都能找到对应解法。而且Android Studio对Java的模板支持和重构能力非常成熟对新手友好度更高。如果你想后面转KotlinJava写的代码重构成Kotlin也很顺畅没有什么沉没成本。2.2 网络层与图片加载RxJava Retrofit Glide的经典组合网络层我选了Retrofit 2 RxJava 2 OkHttp 3这套组合。Retrofit负责接口定义和请求转换RxJava负责异步线程切换OkHttp底层做连接管理。这三个配合是我认为的Android开发黄金组合现成的教程和踩坑案例最多。为什么不用协程因为协程虽然更现代但当时的项目周期里团队其实就是我自己对RxJava更熟。RxJava的链式调用对先登录拿Token再带着Token去下单这种依赖请求场景写起来非常顺手。flatMap一串搞定错误处理也集中。协程当然也能做但当时换协程意味着所有网络层要重写成本不划算。这个选择给你做参考如果你刚起步用协程也行但一定要把要网络层的错误处理设计好否则后面联调会非常痛苦。图片加载用的Glide原因就一条它对生命周期感知做得好。列表滚动的图片加载Glide会自动暂停/恢复避免内存抖动。再加上校园零食的商品图片大多是从供应商那边拿的图片尺寸不统一Glide的CenterCrop变换能统一裁剪成正方形视觉效果整齐很多。提示Glide加载网络图片时记得在ImageView上设置占位图不然弱网环境下用户看到的是一块白屏很容易被误判为BUG。2.3 本地存储与数据缓存SharedPreferences SQLite的组合策略购物车数据、登录状态、用户偏好这些轻量数据我用SharedPreferences存JSON字符串订单历史这类结构化数据我用SQLite存。为什么这么拆分因为购物车数据量小、读写频繁SharedPreferences的异步读写足够用做成本地缓存后即使断网也能浏览已加载过的商品列表。SQLite这块我用的是原生API没有上ORM框架。虽然GreenDAO和Room更省事但原生SQL写得多反而对数据库理解更深。比如订单表和订单详情表的主外键关联、批量插入事务、分页查询这些用原生SQLite写一遍后面再看Room会觉得特别轻松。数据缓存策略上我给商品列表做了二级缓存优先读内存、其次读本地、最后走网络。内存缓存用的LruCache本地缓存用SharedPreferences存JSON网络请求成功后同时更新这两层。这个策略对校园网不稳定、宿舍区信号差的场景非常实用实测下来弱网环境首屏加载速度提升了至少两倍。3. 核心功能模块拆解从登录到订单签收的完整闭环3.1 登录注册模块验证码倒计时与Token机制用户端我做了手机号密码的注册登录外加一个短信验证码登录。这里有一个容易被忽略的细节短信验证码的倒计时按钮必须做成服务端控制而不是本地控制。很多新手直接在客户端写一个CircleCountDownView本地倒计时60秒用户杀掉APP重进就又能点了这会带来安全隐患。我当时的做法是后端生成验证码后设置一个60秒的过期时间客户端每次点击发送前先请求一次是否可发送的接口服务端返回剩余秒数客户端再做倒计时展示。登录成功后后端返回一个Token我存在SharedPreferences里并在OkHttp的拦截器里统一加上Authorization头。Token过期时拦截器捕获401错误并自动跳转登录页。这个机制的坑在于如果你用同步拦截器处理Token刷新要注意死循环问题。我见过的网友踩过这个坑——刷新Token的接口自己也带上了拦截器结果每次走网络层都去刷新Token形成死循环。所以拦截器里必须做一个标记判断只对非刷新Token的请求做拦截处理。3.2 首页与商品浏览Banner轮播、九宫格分类和瀑布流商品卡片首页是用户对APP的第一印象我用了CoordinatorLayout AppBarLayout ViewPager2搭骨架。顶部是搜索框定位入口下面跟着一个Banner轮播图再往下是九宫格式的商品分类入口最下面是商品列表的双列瀑布流。这套结构被无数电商APP验证过用户不需要学习成本。Banner轮播图的实现我并没有直接用现成的轮播库而是自己封装了一个基于ViewPager2的循环轮播。为什么要自己写因为市面上的轮播库大多基于废弃的ViewPager改造到ViewPager2需要处理Item复用、无限循环和手指滑动冲突自己写一遍这些坑就全明白了。核心逻辑是给Adapter设置一个很大的初始值例如Integer.MAX_VALUE / 2配合setCurrentItem跳转到中间位置实现假装无限循环。定时轮播用Handler的postDelayed注意在onPause和onResume里暂停和恢复不然页面切后台时图片还在滚既费电又容易OOM。九宫格分类我用的GridView但要说清楚一点GridView不是不能用只是不适合数据需要动态增减的场景。我当时分类是固定的六个所以无所谓。如果你的分类数据是服务端下发、数量不确定建议直接用RecyclerView把spanCount设成4效果一样扩展性还好。商品列表的双列瀑布流我用了RecyclerView StaggeredGridLayoutManager。这个布局管理器有一个经典坑item高度变化时position会发生错乱。比如你下拉刷新后插入了新数据滚动位置会莫名其妙地跳动。解决办法是在Adapter里重写getItemId并给StaggeredGridLayoutManager设置setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_NONE)然后配合notifyDataSetChanged。如果你不想折腾可以直接用两列高度一致的卡片方案视觉上更稳定开发量也小很多。3.3 购物车模块本地缓存与状态同步的三种方案对比购物车是这个APP里最有状态管理色彩的模块。我试过三种方案这里直接分享对比结果。第一种是直接把购物车数据全量放到SharedPreferences。优点是实现简单一个JSON搞定缺点是多个页面同时操作购物车时内存数据和本地数据容易不一致。比如列表页加了商品购物车页面读到的却是旧数据需要在onResume里重新读取。第二种是放在Application级别的全局变量里用单例管理。数据一致性好了但是APP进程被杀后数据丢失购物车作为暂存数据影响不大但用户体验很割裂。第三种就是最终采用的方案单例持有 SharePreferences持久化 观察者模式通知。每次增删改购物车先更新内存再异步写入本地同时发出一个事件通知所有监听页面刷新。页面销毁时反注册监听避免内存泄漏。这里的核心经验是不要试图做一个绝对实时同步的购物车而是把数据流设计成单向的——操作方负责更新展示方负责监听。这样无论从哪个页面进购物车看到的都是统一状态。我之前就是在这个模块上返工了三次痛感很深希望你能一次到位。3.4 下单支付流程订单状态机的设计下单流程看起来简单确认商品、填地址、提交订单、支付。但真正写代码时你会发现订单状态流转才是最复杂的部分。我定义了一个订单状态枚举待支付0、待接单1、配送中2、已完成3、已取消4、退款中5。每个状态对应一组可操作的动作比如待支付可以取消、待接单可以催单、配送中可以联系配送员。状态机写清楚了后面做推送通知和页面展示就顺了。我给订单列表的每个状态配了不同的操作按钮和背景色服务端推送过来的状态变更消息客户端根据newState刷新对应条目。这里有一个测试时的坑服务端和客户端的状态枚举必须保持一致。当时我和写后端的同学各自定义了一套导致配送中在客户端显示成已完成排查了半天才发现是两个项目里数字对应关系错了。支付方面我接的是支付宝和微信的SDK。有一个经验值得分享支付回调绝对不能只依赖客户端。支付宝SDK的同步返回结果只是参考真实支付结果必须以后端服务器收到异步通知为准。所以我下单时生成了商户订单号支付成功后把支付宝回调里拿到的trade状态传给后端后端再查一次支付宝接口确认最后才把订单状态改成已支付。这套双校验机制虽然多写了一点代码但能挡住伪造回调的情况。3.5 配送跟踪前台Service 广播实现实时位置更新配送员端我做得比较轻就是一个简单的状态更新但用户端要看配送进度这就涉及前台服务和位置权限了。我在用户端做了一个前台Service在订单配送中状态下启动每秒接收后端推送的配送员经纬度更新地图上的标记。这里重点说一下Android 8.0之后的坑后台Service启动限制。你从后台Activity启动一个前台Service必须在startForegroundService()之后5秒内调用startForeground()否则直接抛异常。而且Android 10之后从后台启动Activity也被限制了。我的做法是用户点击查看配送进度按钮这个操作本身属于前台活跃状态这时候启动前台Service是没有问题的。但如果你想让Service在APP切到后台后继续跑记得在AndroidManifest里声明FOREGROUND_SERVICE权限并且在Service的onStartCommand里创建通知栏常驻通知。地图这块我用的高德地图SDK。高德地图有一个很烦的坑Key配置错误时地图会白屏但不报错。我在集成时找了好半天才发现是SHA1和包名不匹配。建议你在做地图之前先单独写一个小Demo验证Key确认能显示出地图再集成到项目里省得后面排查时根本不知道是地图SDK的问题还是自己代码的问题。4. Android开发绕不开的坑权限、FileProvider、Gradle和卡顿4.1 权限体系从安装时授权到运行时授权的演变校园配送APP涉及打电话、读定位、拍照片、写存储这些权限权限适配如果不做真机上会闪退或者功能不可用。Android的权限体系经历了三个阶段Android 6.0之前是安装时授权6.0之后变成了运行时授权Android 8.0对通知栏权限做了细分Android 10之后又搞了分区存储。我开发时目标SDK设置的是Android 10或Android 11所以必须处理动态权限。动态权限的核心是三步检查权限是否已授予、未授予则请求、处理回调结果。我在MainActivity里封装了一个PermissionHelper专门处理权限申请逻辑。这里有一个容易被忽略的细节被拒绝过一次后第二次请求时系统会不再弹窗而是直接返回denied并附带shouldShowRequestPermissionRationale为false。这时候正确的做法是引导用户去设置页手动开启不然用户会以为APP坏了。注意在Android 10及以上的设备上读写外部存储不能用传统的WRITE_EXTERNAL_STORAGE做全盘读写必须用分区存储的API。我的做法是APP文件全部写到getExternalFilesDir()目录下这样既不用申请存储权限也不会踩分区存储的坑。如果你必须访问公共目录记得用MediaStore API不要直接拼绝对路径。4.2 FileProvider配置一个必踩的坑这个坑几乎是Android开发必踩一次用FileProvider分享文件结果报错Cannot find configured parent或直接闪退。热搜词里一堆content://开头的东西就是因为这个。事情是这样的我用FileProvider把APP生成的配送单PDF分享给用户Android 7.0之后不允许直接暴露file://Uri必须用content://Uri FileProvider。如果你的配置不对系统根本找不到对应的cache-path或external-path就直接抛FileUriExposedException。我的配置方式是这样的在AndroidManifest的application节点下加provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider然后res/xml/file_paths.xml里paths external-path nameexternal path. / cache-path namecache path. / /paths就这么一段配置。但坑点在于如果authorities里的包名和build.gradle里applicationId不一致就会报authority not found。我见过有人把authorities写成本地项目的硬编码包名结果用了productFlavors切换包名后分享功能就失效了。坚持用${applicationId}动态获取不要在配置里写死。另外再说一个FileProvider相关的隐藏问题当你用系统相机拍照后要把拍照返回的Uri传给裁剪工具如果这个Uri在FileProvider里没有配置对应路径裁剪页面会直接黑屏或崩溃。所以拍照文件的路径要么放在cache-path下要么在file_paths里额外增加一条files-path来指向你的存储目录。这个问题我在做用户头像上传时踩过排查了一下午。4.3 Gradle依赖冲突could not determine the dependencies of task热搜词里那条could not determine the dependencies of task :app:compileDebugJavaWithJavac我敢说做过Android开发的人一定见过类似的。这个报错通常不是某个依赖本身的问题而是依赖版本冲突导致的解析失败。我的处理思路是三步走。第一看完整错误栈里提到的库名找到冲突项第二用./gradlew :app:dependencies命令输出完整的依赖树定位冲突位置第三在build.gradle里用exclude排除冲突模块或强制指定版本。举个例子我当时用了Glide 4.9.0和support库的v4包结果v4包里自带的support-compat和Glide依赖的support-compat版本不一致直接导致编译不过。解决办法就是在Glide的依赖上加implementation(com.github.bumptech.glide:glide:4.9.0) { exclude group: com.android.support, module: support-compat }还有一个经验永远不要为了编译通过而随便改targetSdkVersion或compileSdkVersion。降版本当时能跑后面真机测试的时候会碰到一堆兼容性怪问题而且Google Play上架也要求targetSdkVersion不低于某个值。依赖冲突就老老实实解冲突这是正向路径。4.4 列表卡顿治理从帧率监控到布局优化校园配送APP的商品列表是使用频率最高的页面卡顿问题如果在开发机上不明显一到真机尤其是低端机上就会暴露。我做了一轮完整的性能优化思路整理给你。第一步是监控帧率。Android Studio自带的Profile工具可以看到GPU渲染的帧率曲线低于16.6ms就代表掉帧。我在测试时发现商品列表的图片一多每帧渲染时间能到40ms卡得没眼看。第二步是找根因。用Profile看耗时方法发现大头在图片加载和Item布局的嵌套层级上。图片加载优化很简单Glide里加上override(200,200)强制缩略图再用thumbnail()先加载小图再加载大图内存占用直接降一半。布局嵌套方面我的商品卡片用了三层RelativeLayout,后来改成了ConstraintLayout一层完成实时GPU渲染时间从40ms降到了20ms以内。第三步是全局优化。给RecyclerView加上setHasFixedSize(true)避免内容变化触发重新测量给item的根布局设置clipChildrenfalse和clipToPaddingfalse让列表滑动到边缘时卡片能露出一点背景视觉上有沉浸感滑动流畅度也会更好。5. 服务端方案选型从Bmob到Spring Boot的自建后端5.1 为什么最终放弃BaaS平台刚开始图省事我用了Bmob这类BaaS平台想着数据库、鉴权、云函数都有了而且APP可以直接连上不用自己写后端。但做了两周后我放弃了原因有三。第一BaaS平台的免费额度经不住联调。每天请求次数有限加上图片上传流量消耗开发期就把钱花光了。第二业务自由度太低。我后面要加满减优惠拼单这类营销玩法Bmob的查询语法写起来非常别扭不如SQL直接。第三后端逻辑没法独立测试。订单状态机、库存扣减这些都是强业务逻辑放在客户端做既不安全也不可靠。所以我后来选择了自建后端用Spring Boot写RESTful APIMySQL存数据。也不用花哨的微服务那一套就是单体应用加一个Redis缓存商品列表和Token部署在一台轻量云服务器上。如果你的项目是毕业设计这整套方案答辩时能讲的东西也更多。5.2 后端接口设计与数据表结构订单相关的表我设计了五张用户表、商品表、购物车表其实客户端本地也有一份、订单表、订单详情表。这里有一个我在初期设计时忽略、后来很吃亏的地方订单表和订单详情表必须分开。如果你图省事把商品快照直接存在订单表的一个JSON字段里那后面要做订单统计、热销商品分析的时候你哭都来不及。商品快照是我建议一定要做的字段。用户下单那一刻商品的价格、名称、规格、图片都要原样存进订单详情表。因为商家后续可能改名、改价、甚至下架商品如果订单表只存了一个商品ID历史订单显示的商品信息全变了这在真实业务里是大忌。我最初没加快照字段上线后商家改了一次价格之前的订单价格全被联动更新了看到用户投诉时整个后背发凉。后端的鉴权我用的JWT配合Spring Security做了简单的认证和授权。学生端和配送员端各一个角色管理员也是通过角色区分。JWT的无状态特性很适合这种不需要Session的移动端接口。5.3 前后端联调的常见问题编码、时区与空值联调是项目后期最耗时的环节。我遇到过的坑总结一下让你提前避开。编码问题后端返回的中文在客户端显示为乱码十有八九是Tomcat没有设置UTF-8编码。解决办法是在Spring Boot的application.yml里设置server.tomcat.uri-encoding为UTF-8。时区问题MySQL默认用的是系统时区如果你服务器时区不是东八区订单提交时间就会差8个小时。我的做法是数据库连接串上加serverTimezoneAsia/Shanghai并且在实体类上用LocalDateTime而不是Date前端展示时统一转成时间戳。空值问题后端返回的JSON字段如果是null客户端解析时用Gson或FastJson默认不会报错但如果你用Kotlin的data class解析非空字段直接崩。我处理的方式是请求封装成统一返回结构data字段单独解析空数据返回一个空的JSON对象而不是null。提示我这里说的统一返回结构是{ code: 200, message: success, data: {...} }这种格式。客户端写一个BaseResponse 泛型类用Gson的TypeToken解析。你自己做的时候一定把这个规范定好不然前后端各写各的联调得拆分八次。6. 从模拟器到真机测试与发布上架的实操笔记6.1 模拟器测试的局限性这三种问题必须真机验证Android模拟器我平时开发用得多但测试阶段真机无法替代。总结下我吃过亏的三种情况。第一碰撞检测和传感器。模拟器里的虚拟传感器数据不准计步、摇一摇等操作没法验证。校园配送APP里没有用到传感器但这个原则通用。第二网络状态切换。模拟器切换移动网络和WiFi非常不真实而校园里宿舍区WiFi差、楼道里信号弱的场景只能在真机上测。我用一台红米Note和一台荣耀播放器轮流开WiFi和流量专门测试了断网重连和弱网加载。第三推送和通知权限。模拟器对通知权限的弹窗逻辑和厂商Rom差异很大尤其是OPPO、vivo这些机型默认禁止应用自启动后台推送经常被干掉。这里想强调权限适配不能只看自己的代码还要看厂商的管控策略。6.2 打包签名从debug签名到正式签名的切换开发阶段我用的是debug签名但发布前必须换成正式签名。签名不匹配会导致已安装用户无法覆盖安装而且一旦签名丢失就永远无法升级所以签名文件的备份和安全格外重要。我创建了一个.jks签名文件并在build.gradle里做了签名配置android { signingConfigs { release { storeFile file(../keystore/release.jks) storePassword your-password keyAlias your-alias keyPassword your-password } } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro signingConfig signingConfigs.release } } }这里要提醒不要把密码明文提交到Git仓库。我当时直接把密码写在build.gradle里后来发现Git历史里全是明文密码虽然项目没开源但这种坏习惯必须改。正确做法是用gradle.properties里存变量或者设置环境变量构建时动态读取。6.3 上架应用市场的流程与注意事项如果只是校内自用不一定要上架但如果你想让别人能真正安装使用至少要发到一两个应用市场。我走的是酷安和应用宝。上架过程有几个注意点应用名称不能和已有应用重复需要提供软件著作权证书有的市场不需要。市场审核时比较在意权限的合理性比如你一个零食APP声明了读取联系人权限直接被打回。所以发布前我重新梳理了一遍权限申请清单把用不到的全部移除。另外要处理的是隐私政策页面。现在应用市场基本都强制要求提供隐私政策不然无法通过审核或上架后下架。我在后台自己写了一个静态页面APP首次启动时弹窗让用户阅读并同意不同意就退出。这个不只是合规要求也是对自己用户负责。提示上架时包名要选好因为包名一旦发布不可更改。我用的包名是com.example.campusnacks如果你要长期做建议换成自己的域名倒置格式比如com.yourname.campusnacks显得更正规。6.4 发布上线后的运营数据这些指标才是真的成绩单APP发出去之后我的关注重点变了。以前觉得用户active用户数DAU才是成绩后来发现次日留存率和人均启动次数更有参考价值。我在APP里埋了启动事件和下单事件用友盟来做统计很轻量也不收费。校园配送APP上线两个月的数据大概是次日留存37%人均启动次数4.2次日均订单30单左右。这个数据放在商业市场不算好看但在单一校园场景里已经证明了需求真实存在。更重要的是这些数据可以直接写进自己的文档里作为项目分析和优化的依据。如果你也想统计建议在APP启动时、加入购物车时、提交订单时、支付成功时这四个节点打点。数据维度不用太丰富选两三个关键指标持续观察就够。7. 我的三点收尾建议项目做到这里回头看整个开发过程我最想强调的其实不是某个技术点而是三件容易被忽略的事。第一需求一定要写清楚再动手。我当初写了一份简单的需求文档虽然不专业但在开发摇摆不定时拿来回看能迅速让我冷静下来。校园项目的诱惑是想到什么加什么结果功能越做越多、质量越做越糙。守住需求边界功能收窄反而更能做深。第二版本控制永远不要懒。我保留了每一个大版本的Git标签回滚和对比都非常方便。有些同学习惯复制文件夹来备份代码一旦文件夹多了改错代码想还原都找不到准确版本。Git是硬技能不会就花一个晚上学一下后面省下的时间绝对值得。第三自己写过的坑要记录成文档。我在项目根目录建了一个BUGS.md文件每次踩坑解决后都记录现象、原因、解决方案、影响范围。这个习惯帮我避免了很多重复踩坑而且项目答辩时拿出这份文档比你口述十遍都更有说服力。如果你也要做校园类Android项目建议按这个顺序走一遍先把痛点想清楚再定技术方案然后一个模块一个模块地实现遇到问题就记录下来最后做一轮完整测试再发布。整个过程最难的不是写代码而是坚持把功能做得完整、把细节处理得干净。这个项目做完你对Android开发的理解会上一个台阶至少以后再遇到Handler、RecyclerView、权限、FileProvider这些名词心里是踏实的。