
简介面向Android初中级开发者的仿饿了么项目源码围绕本地功能实现覆盖RecyclerView列表、图片加载、动画交互、Fragment多屏适配、本地存储、权限与异步任务等知识点适合课程设计或初学者进阶。压缩包共348个文件约6.57MB包含36个java源码、87个xml布局与配置、87个class编译产物、111个png图片资源并有apk安装包、jar库等结构清晰。目前已有282人学习下载。通过项目源码与资源文件对照可掌握仿主流App的界面搭建、交互逻辑和本地数据持久化方法在本地功能基础上可继续扩展网络请求、地图定位等模块逐步完善为完整应用。1. 一个没有网络层的仿饿了么为什么它更适合拆开读源码拆过几个仿写类 App 之后你会发现真正能带来信息量的往往不是那种功能完整、网络层健壮的成品工程而是这种刻意做成单机版的教学项目。仿饿了么源码最吸引人的地方是它几乎不涉及网络请求首页、商家详情、购物车角标全在本地跑。源码里保留了 HomeFragment、RestaurantDetailActivity、BadgeView、StickyListHeadersListView 这些类配合 huanry.apk 可以直接装到设备上体验交互效果resources.ap_ 则是编译后的资源打包产物想看它的资源命名规范用 apktool 反解一下就行。没有网络层反而把阅读焦点全部压缩到 UI 架构和事件流上适合想系统梳理 Android 界面体系的初学者也适合正要自己搭外卖类 MVP 的客户端工程师。2. 从 HomeFragment 看 Fragment 容器设计与本地假数据模拟2.1 为什么先看 MainActivity 再进 HomeFragment拿到一份没有网络层的 Android 源码别急着打开 XML先找入口类。工程结构里同时存在 Activity 和 Fragment说明它采用的是单 Activity 多 Fragment 的组织方式。打开 MainActivity常见做法是setContentView(R.layout.activity_main)之后在onCreate里创建一个 HomeFragment 并提交到容器public class MainActivity extends Activity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); HomeFragment homeFragment new HomeFragment(); getFragmentManager() .beginTransaction() .replace(R.id.main_container, homeFragment) .commit(); } }replace的核心语义是先移除容器内已有 Fragment再添加新 Fragment这样能避免 Fragment 层层叠加导致的状态错乱。这里没有用add是因为外卖 App 底部 Tab 切换时页面状态需要重置如果你自己的项目里希望保留页面位置与滚动状态才应该用add配合hide/show。HomeFragment 的布局既可以用RecyclerView承载商家列表也可以像这份源码这样基于 ListView 家族实现。注意getFragmentManager()在 API 28 之后标记为 deprecated新的工程建议使用getSupportFragmentManager()并且 Activity 继承AppCompatActivity。这对 5 年以上经验的读者来说不是新知识点但对着这份旧源码改造成新架构时是必然会撞到的第一道坎。2.2 本地假数据仓库的设计仿饿了么最大的实用价值是它把数据边界画得很清楚。整个应用没有任何网络模块数据源就是内存里的List。我倾向于把这份数据源单独抽象成一个 Repository 类这样后续替换成 Retrofit 或 Room 时改动范围能收在接口边界之内public class MockRestaurantRepository { public ListRestaurant load() { ListRestaurant restaurants new ArrayList(); restaurants.add(new Restaurant(阿大烧腊, 4.7f, 1280, 18, 2.0f, null, true)); restaurants.add(new Restaurant(沙县小吃步行街店, 4.4f, 3120, 6, 1.0f, null, false)); restaurants.add(new Restaurant(湘辣小厨, 4.2f, 860, 23, 3.0f, 10, true)); return restaurants; } }这里Restaurant的字段设计值得拷贝它基本对应着外卖卡片上所有可见信息name对应商家名rating对应评分星级monthlySales对应月售distance用于“距离/配送时间”文案shippingFee对应配送费hasCoupon决定是否在卡片右上角渲染“减”字角标。列表 Adapter 拿到数据后按字段逐一绑定到 ViewHolder。看清楚distance和monthlySales这些字段怎么在列表里被格式化输出会比纠结 Glide 的占位图参数更接近问题的本质。Restaurant 字段类型列表 UI 呈现nameString商家名 TextViewratingfloat星级评分控件monthlySalesint“月售 1280” 文案distancedouble“距离 1.8km” 文案shippingFeefloat“配送费 2 元” 文案hasCouponboolean卡片右上角“减”的切换2.3 列表页的空状态与加载状态处理没有网络层不代表没有状态切换。列表页通常会区分加载中、加载成功、加载失败、数据为空四种状态。这份源码里用的是 RefreshableListView所以处理状态的方式和 RecyclerView 略有不同下拉刷新的 Loading View 和空视图都是直接挂在 ListView 上的。常见做法是在布局里预置一个空状态布局然后调用refreshableListView.setEmptyView(findViewById(R.id.view_empty));setEmptyView会在 Adapter 数据为空时自动隐藏 ListView并显示空视图不需要手动切换可见性。加载中状态一般用一个覆盖在列表上方的 LoadingView 来控制。这里有个容易被带偏的细节不要用ListView的setVisibility去做空状态切换因为 Adapter 数据变化时ListView 内部会重新计算isEmpty并触发setEmptyView逻辑手动控制会导致状态叠加刷新完成时空视图还能留在界面上。把数据源、列表控件、空状态三者之间的联动关系理清楚才能在后续接入真实网络库时不被状态同步问题绊住。3. StickyListHeadersListView 与 RefreshableListView 的实现细节3.1 吸顶头的关键在 getHeaderId 的返回值商家详情页在仿饿了么里是典型的双栏结构左侧是菜品分类右侧是对应分类下的菜品列表。为了让用户滚动时始终知道自己在哪个分类列表顶部需要一个吸顶的分组头。这份源码选择的是StickyListHeadersListView它的核心思想基于 ListView 的适配器扩展。实现吸顶效果并不需要自定义 ListView只需要让 Adapter 实现StickyListHeadersAdapter接口并正确返回两个方法public class MenuListAdapter extends BaseAdapter implements StickyListHeadersAdapter { private ListMenuItem menuItems; Override public long getHeaderId(int position) { return menuItems.get(position).getCategoryId(); } Override public View getHeaderView(int position, View convertView, ViewGroup parent) { View headerView; if (convertView null) { headerView LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_menu_header, parent, false); } else { headerView convertView; } ((TextView) headerView.findViewById(R.id.tv_category)) .setText(menuItems.get(position).getCategoryName()); return headerView; } }getHeaderId是整个吸顶机制的判定基准列表滚动的过程中库内部会对比当前位置和前一个位置的headerId只要 id 发生了变化就认为当前应该切换头部内容相同的 id 值则意味着这两个位置的条目属于同一个分组头部保持不变。这个设计比 RecyclerView 的ItemDecoration更容易理解也是这份源码教学价值比新版实现更直观的原因——看到 id 对比就能明白吸顶头本质上是“跟随列表滚动的特殊列表项”。如果要在 Android Studio 里自己实现一套类似效果用 RecyclerView 时通常是重写onDrawOver在 Canvas 上绘制一个固定的 header 视图但处理点击事件、动画过渡时要补的边界条件比这个库多得多。3.2 下拉刷新的触摸判定与回调机制RefreshableListView 是另一个需要细看的类。它的刷新机制从原理上可以拆成三段拦截触摸事件、判断下拉距离、触发刷新回调。核心是重写onTouchEvent在ACTION_DOWN时记录起始 Y 坐标ACTION_MOVE时计算位移差当位移超过阈值时切换到刷新状态refreshableListView.setOnRefreshListener(new RefreshableListView.OnRefreshListener() { Override public void onRefresh() { // 模拟网络请求 new Handler().postDelayed(new Runnable() { Override public void run() { refreshableListView.setRefreshing(false); } }, 1200); } });这里的setRefreshing(false)必须被调用它负责两件事把头部 View 收起并把内部刷新状态位复位。如果回调里忘了调用ListView 会一直卡在“刷新中”的状态头部无法回弹。模拟刷新时一般用Handler.postDelayed或ScheduledExecutorService来产生延迟效果模拟真实网络耗时。这个库内部对 ACTION_MOVE 的处理没有做滑动冲突拦截所以如果页面里还嵌套了纵向 ScrollView需要给 RefreshableListView 设置setInterceptTouchEvents(true)不然会出现手指上滑时下拉刷新的头部也被拉出来的尴尬情况。3.3 参数配置与高频踩坑点使用 RefreshableListView 的配置项不多但是有四个高频问题值得记下来参数/场景配置方式说明下拉触发距离setDistanceToTriggerRefresh(int)默认值通常偏小建议设为 80~120dp头部收起setRefreshing(false)刷新完成后必须调用否则卡死快速滚动白屏setDividerHeight(0)关闭分割线可减少缓存复用时的绘制开销吸顶头点击事件setOnHeaderClickListenerheader 复用时需设置相同 listener避免闪烁吸顶头的点击事件是一个很容易疏忽的点因为getHeaderView是基于 position 的列表滚动时头部视图会被回收复用。如果在getHeaderView里直接setOnClickListener每次复用都会重新创建 listener 对象Android Studio 的 lint 会提示匿名内部类的内存泄漏风险。正确的做法是在 Adapter 构造时初始化一个 listener 实例然后在getHeaderView里统一赋值。另外一个和 Android 版本相关的坑是Android 7.0 之后 ListView 的快速滚动条在 view 复用时会偶发残留阴影在onHeaderViewMoved回调里手动调用invalidate()可以缓解。4. 商家详情、购物车角标与 BadgeView 的交互实现4.1 RestaurantDetailActivity 的页面组织RestaurantDetailActivity在源码里是承载商家详情页的 Activity。它要解决的核心布局问题是顶部商家信息区固定不动下方菜品列表滚动自由。观察 class 列表里有RestaurantDetailAdapter这个 Adapter 对应的列表项通常包含菜品图片、名称、月售、评分、价格和“加入购物车”按钮。由于没有网络层详情页的数据来源同样是本地 mock用一个静态方法传入restaurantId再根据 id 返回对应的商家详情和菜品数组。这里有一个可以从源码里学到的设计RestaurantDetailAdapter和首页商家列表 Adapter 是分开的。首页的 Adapter 关注卡片信息展示详情页的 Adapter 关注 Selectable 状态比如某些菜品标识“售罄”或“招牌”这两种状态不应该复用同一个 ViewType。仿饿了么的做法是把详情页的列表项拆成多个 ViewTypegetViewTypeCount()返回 3 种普通菜品类、推荐菜品类、分隔条类。还用到了一个开发细节详情页的加入购物车按钮使用Tag存储菜品的在列表中的索引位置而不是直接存对象引用这是为了避免 ListView 复用导致点击时对象映射错乱处理思路值得借鉴。4.2 BadgeView 的挂载顺序与数字更新购物车角标是外卖应用交互里最有辨识度的一个控件。仿饿了么源码里出现BadgeView.class这类控件在开源社区有多个版本其中使用频率最高的基于一个带数字的小红点 View可以挂载到任意父 View 上。加点菜时通常调用它做三件事badgeView.setBadgeCount(cartItemCount); badgeView.setBackgroundColor(getResources().getColor(R.color.cart_badge)); badgeView.setTargetView(btn_cart); badgeView.setBadgeShown(true);setBadgeCount传入购物车中所有菜品的总份数而不是菜品种类数这是产品逻辑上的一个隐藏细节购物车角标统计的是总件数。setTargetView会把 BadgeView 挂到购物车图标的右上角基于目标 View 的位置信息自动计算偏移。调用顺序上有一个小坑先setBadgeCount再setTargetView因为setTargetView会触发一次内部重绘如果顺序反了首次渲染时角标数字可能显示为上一次的旧值。// 加购完成后的角标动画 iv_food.animate() .scaleX(0.6f).scaleY(0.6f) .alpha(0.4f) .setDuration(180) .withEndAction(new Runnable() { Override public void run() { iv_food.setVisibility(View.VISIBLE); iv_food.animate() .scaleX(1f).scaleY(1f) .alpha(1f) .setDuration(180); } });这段动画的思路是模拟“菜品被加入购物车”的反馈先缩小并淡出再恢复原状让用户感知到按钮被触发过。ViewPropertyAnimator对象是可复用的但withEndAction里的 Runnable 在动画取消时也会被调用所以如果用户快速重复点击加购按钮会出现画面突然闪一下的现象。规避手段是加一个布尔类型的防抖标记在动画执行期间忽略后续点击。在较新的 Android 版本上scaleX和alpha这类属性动画结合硬件加速已经没有兼容性问题但需要注意用getWindow().setWindowAnimations()设置页面转场动画时属性动画可能受全局动画缩放比例的影响。4.3 权限与存储的适配这份源码虽然不需要网络但加载本地图片素材时如果图片来自外部存储则必须处理运行时权限。Android 6.0 之后READ_EXTERNAL_STORAGE属于危险权限需要动态申请。如果你的 Android Studio 设备是 Pixel 模拟器每次冷启动后权限会被重置所以调试前要先用adb shell pm grant packageName android.permission.READ_EXTERNAL_STORAGE预授权。仿饿了么这种教学项目一般会直接在AndroidManifest.xml里写上权限声明但不会写动态申请代码这是一个适合自己补全的练习点。这里有一个细节如果仅把图片资源放在res/drawable或res/mipmap下通过资源 ID 加载就不需要任何运行时权限需要权限的场景是把图片文件放在sdcard或getExternalFilesDir中再通过路径读取。源码里resources.ap_本身就是编译后的资源包反解后能看到整套资源规范化命名是了解 mipmap/drawable 分类的现成参考。5. 给这个项目接上 ViewModel、Room 和 Retrofit 的改造清单费曼学习法在这个项目上的实践是先读懂本地数据流向再把它替换成真实架构。改造不是推倒重来而是做三步递进。第一步把MockRestaurantRepository抽象成接口RestaurantRepository让它有两个实现MockRestaurantRepository和RemoteRestaurantRepository。第二步在RemoteRestaurantRepository里用 Retrofit 定义接口public interface RestaurantApi { GET(restaurants) CallListRestaurant listRestaurants(Query(cityId) int cityId); GET(restaurants/{id}/menu) CallListMenuItem getMenu(Path(id) long restaurantId); }把Query和Path两种传参方式放到同一个接口里能覆盖列表和详情页两个场景。第三步为离线缓存引入 Room将Restaurant实体加上Entity(tableName restaurant)注解主键设为id然后创建一个RestaurantDao用Query(SELECT * FROM restaurant WHERE cityId :cityId)做本地查询。ViewModel 中的切换策略是优先读 Room没有再走 Retrofit拿到网络数据后回写 Room这样即使断网也能保证列表页可用。验证改造是否破坏原有逻辑的方法跟网络无关用 Android SDK 自带的工具就能完成adb shell dumpsys activity top | grep ACTIVITY adb logcat -s HomeFragment:D AppLog:E *:Sdumpsys activity top可以实时查看当前栈顶的 Activity 和 Fragment 状态logcat按 TAG 过滤日志能快速确认页面加载过程是否触发了 Repository 的异常分支。把HomeFragment的日志 TAG 统一命名为HomeFragment刷新回调里打一行Log.d(HomeFragment, refresh triggered)就可以在 logcat 里验证下拉刷新被点击后回调链完整走通。改造完成之后可以顺手做一件事把MockRestaurantRepository里 mock 数据源替换为 assets 目录下的 JSON 文件用Gson解析这样既不引入网络层又能让数据层面的修改脱离 Java 代码重新编译——整个过程保持在这个工程最小的依赖范围之内这也是旧项目改造时最安全的推进节奏。本文还有配套的精品资源点击获取