简介:面向安卓开发者的类大众点评APP完整源程序,基于Eclipse环境用Java构建,适合想系统掌握生活服务类应用架构、已有一定Android基础的中级开发者。工程覆盖页面构建所涉及的LinearLayout/RelativeLayout/RecyclerView布局、自定义View优化交互,SQLite与SharedPreferences双通道数据管理,HttpURLConnection/OkHttp网络请求及Gson/Jackson解析,AsyncTask与Handler异步处理,Android 6.0+动态权限申请,地图与GPS定位,Picasso/Glide图片缓存,商家评分评论、Firebase推送及微信/支付宝支付等模块,导入后即可对照代码研究各模块间的协作与数据流转。资源包共245个文件,仅966KB,内含101张png界面素材、67份xml布局与配置、18个java源文件,另有编译生成的class、apk、db、properties及proguard混淆配置,方便边运行边比对实现。已有633人学习下载。通过拆解该项目,可明确商用级安卓工程的功能分层、数据库设计及三方SDK接入套路,对从零散知识点走向完整项目开发很有帮助。
1. 类大众点评APP源码:一套能直接跑通的安卓本地生活应用复现方案
做点评类App最耗时间的从来不是界面,而是把商家、点评、订单、搜索这些模块串成一个能演示的闭环。这套源码属于典型的安卓本地生活工程,Java 编写,数据默认落在 SQLite,商家列表、关键词搜索、商圈筛选、距离排序、详情点评、下单流程这些入口都有现成代码。它适合两类人:一类是在做课程设计和毕业设计,需要一套结构清晰又跑得起来的基线;另一类是准备接私活或求职,想快速看明白点评类产品核心链路怎么实现的开发者。
整份工程最大的特点是分层不重。Activity 只做页面跳转和生命周期管理,数据访问集中在 db 和 network 两个包,业务字段收敛在 model 层。这和我见过的很多把两万行代码堆在 Activity 里的课程设计不一样,后面改数据源、加功能都留了余地。接下来按工程结构、核心模块实现、踩坑记录和进阶改造四步,把这套源码从头到尾拆一遍。
2. 工程结构与数据层:包路由、三张核心表和网络封装
2.1 包划分与启动路由:从闪屏页到三个Tab的状态管理
解压后先看 app/src/main/java 目录,七个包的分工很清楚。activity 里只放 SplashActivity 和 MainActivity,fragment 封装首页、搜索、我的三个页面,adapter 负责列表展示,db 是数据库帮助类和 DAO,model 是商家、点评、订单等实体,network 是 OkHttp 封装,utils 放距离换算、时间格式化这类工具。这种拆法的好处是各层职责单一,不像有些工程把数据库操作直接写在 Activity 里,出问题找半天定位不到。
启动流程是 SplashActivity 先检查登录态,再进 MainActivity 的底部三 Tab 路由。MainActivity 用 FragmentTransaction 管理三个 Fragment,核心逻辑在 switchTab 方法里:
private void switchTab(int index) { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); // 先把当前显示的 Fragment 隐藏掉,避免多个页面叠加 if (homeFragment != null) ft.hide(homeFragment); if (searchFragment != null) ft.hide(searchFragment); if (profileFragment != null) ft.hide(profileFragment); switch (index) { case 0: if (homeFragment == null) { homeFragment = new HomeFragment(); ft.add(R.id.fragment_container, homeFragment, "home"); } else { ft.show(homeFragment); } break; case 1: if (searchFragment == null) { searchFragment = new SearchFragment(); ft.add(R.id.fragment_container, searchFragment, "search"); } else { ft.show(searchFragment); } break; case 2: if (profileFragment == null) { profileFragment = new ProfileFragment(); ft.add(R.id.fragment_container, profileFragment, "profile"); } else { ft.show(profileFragment); } break; } ft.commit(); }这段路由代码用的是 hide/show 而不是 replace。第一次切到某个 Tab 时才 add,之后一直复用同一个 Fragment 实例,界面滚动位置能完整保留下来。如果图省事用 replace,每次切换都会重建页面,列表回到顶部,搜索框里已输入的关键词也会被清空,体验差别非常明显。
SplashActivity 里的登录态检查也很直白,就是读取 SharedPreferences 里的布尔标记:
SharedPreferences sp = getSharedPreferences("app_login", MODE_PRIVATE); boolean loggedIn = sp.getBoolean("is_login", false); if (!loggedIn) { startActivity(new Intent(this, LoginActivity.class)); finish(); }代码本身好懂,但要注意一点:真实项目里登录态不能只看布尔值,还要带上 token 过期时间和用户 ID。这套源码的登录是本地模拟校验,接后端时要把它替换成「存在有效 token 才放行」的逻辑,否则会出现用户退出登录后还能进首页的问题。
2.2 三张核心表:商家、点评、订单的字段与关联设计
数据库层用的是原生 SQLiteOpenHelper,DBHelper 里维护了一个版本号。第一次打开 App 时会执行三张建表语句,商家表是最核心的一张:
CREATE TABLE merchant ( merchant_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT, business_area TEXT, address TEXT, lat REAL, lng REAL, avg_rating REAL DEFAULT 4.5, month_sales INTEGER DEFAULT 0 );商家表的字段基本对应首页列表需要的全部展示位:name 显示店名,category 用于分类入口过滤,business_area 用于商圈筛选,lat/lng 存经纬度,avg_rating 是平均评分,month_sales 是月售量。筛选和排序都直接作用在这两列上,没有额外做冗余字段。
点评表和订单表都通过 merchant_id 关联商家:
CREATE TABLE review ( review_id INTEGER PRIMARY KEY AUTOINCREMENT, merchant_id INTEGER NOT NULL, user_name TEXT, content TEXT, rating REAL, create_time TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (merchant_id) REFERENCES merchant(merchant_id) ); CREATE TABLE orders ( order_id INTEGER PRIMARY KEY AUTOINCREMENT, merchant_id INTEGER NOT NULL, user_id INTEGER, goods_name TEXT, price REAL, status INTEGER DEFAULT 0, create_time TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (merchant_id) REFERENCES merchant(merchant_id) );三张表的核心关联字段就是 merchant_id。商家与点评是一对多,商家与订单也是一对多。orders 的 status 字段用 INTEGER 而不是 TEXT,是为了配合后面的状态机枚举做取值约束,避免不同页面各写一套含义不一致的状态字符串。create_time 直接用了 SQLite 内置的 datetime 函数生成本地时间,省了一层 Java 代码转换。
DAO 层写法也比较规矩,没有用字符串拼接 SQL:
public Cursor getMerchantsByArea(String area) { SQLiteDatabase db = getReadableDatabase(); String[] columns = {"merchant_id", "name", "category", "business_area", "avg_rating", "month_sales"}; String selection = "business_area = ?"; String[] args = {area}; return db.query("merchant", columns, selection, args, null, null, "month_sales DESC"); }这里用 selection 和 args 分开传参,而不是拼成"business_area = '" + area + "'",能避免 SQL 注入风险,也省掉手动转义单引号的麻烦。排序参数直接写在 orderBy 里,按 month_sales 降序就得到「销量优先」的商家列表。
关于字段类型有两点建议。lat/lng 一定要用 REAL 存,不要用 TEXT,因为排序阶段要把字符串转回 double,多一次转换还可能丢精度。avg_rating 同理,用 REAL 才能在 SQL 里直接做ORDER BY avg_rating DESC,否则按文本排序会导致 9.9 < 10.5 这种诡异结果。
2.3 网络层封装:ApiClient 的超时、拦截器与回调线程
这套源码的 network 包只放了一个 ApiClient 类,基于 OkHttp 3 封装,代码量不大但参数值得逐一说明:
public class ApiClient { private static final int CONNECT_TIMEOUT = 10; private static final int READ_TIMEOUT = 15; private static final OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) .addInterceptor(new HttpLoggingInterceptor() .setLevel(HttpLoggingInterceptor.Level.BASIC)) .build(); public static void get(String url, Callback callback) { Request request = new Request.Builder() .url(url) .header("Content-Type", "application/json") .build(); client.newCall(request).enqueue(callback); } }连接超时设 10 秒,读取超时设 15 秒,这两个参数在弱网环境下的表现差异很大。连接超时太短会出现「明明在连却连不上」的误报,读取超时太短则大图片或列表接口容易中途断掉。日志拦截器级别用 BASIC,只打印请求方法和响应码,比 FULL 少刷屏,调试阶段够用。
调用方拿到响应后需要用 Gson 把 JSON 字符串反序列化成 List:
ApiClient.get("http://example.com/api/merchants", new Callback() { @Override public void onFailure(Call call, IOException e) { // 网络故障回调,这里不在主线程 } @Override public void onResponse(Call call, Response response) throws IOException { String body = response.body().string(); Type type = new TypeToken<List<Merchant>>(){}.getType(); List<Merchant> list = new Gson().fromJson(body, type); } });注意 enqueue 的回调线程是 OkHttp 的线程池,不是 Android 主线程,所以回调里不能直接操作 UI。常见做法是用 runOnUiThread 切回主线程,或者改成 Retrofit 的 suspend 函数配合协程,那就不用手动切线程了。这套源码默认数据源是 SQLite,ApiClient 更多是为后期联调预留的通道,所以没做线程切换封装,理解这个边界就行。
提示:如果要把这套源码改成网络数据源,建议把 ApiClient 的回调封装成线程安全的回调接口,否则每个页面都要写一遍 runOnUiThread,代码会非常散。
3. 核心功能实现:搜索筛选、距离排序、详情页与下单状态机
3.1 搜索与商圈筛选:数据量不同选不同的实现路径
首页和搜索页都涉及「关键词 + 商圈」两个条件的组合过滤。这套源码在数据量不大的前提下,直接在 Java 层做全量内存过滤,代码很直观:
public void filterData(String keyword, String area) { List<Merchant> filtered = new ArrayList<>(); for (Merchant item : allMerchants) { boolean matchKeyword = (keyword == null || keyword.isEmpty()) || item.getName().contains(keyword) || item.getCategory().contains(keyword); boolean matchArea = (area == null || area.isEmpty() || area.equals("全部")) || item.getBusinessArea().equals(area); if (matchKeyword && matchArea) { filtered.add(item); } } merchantAdapter.setData(filtered); }这里两个条件的判定都用「空值短路」逻辑:关键词没填就默认匹配所有商家,商圈选了「全部」就不过滤。实际使用中要注意 contains 是区分大小写的,如果商家名包含英文字母,建议改成toLowerCase().contains(keyword.toLowerCase()),不然用户搜小写字母时可能漏结果。
内存过滤的性能边界大概在两三千条数据。商家数少的时候直接用这个方案,简单好调;数据量上万后建议把条件拼进 SQL 里,让 SQLite 的索引发挥作用。第二套方案在 DAO 里写好,只需要把 filterData 换成db.query的调用,接口保持返回 List 就行。
3.2 距离排序:Haversine 公式与列表刷新
「附近优先」是点评类产品的核心排序逻辑,这条链路的难点不在公式本身,而在定位坐标的传递。源码里 utils 包放了 GeoUtil 工具类,核心方法是基于 Haversine 公式计算两点间的球面距离:
private static final double EARTH_RADIUS_KM = 6371.0; public static double distanceKm(double lat1, double lng1, double lat2, double lng2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double a = radLat1 - radLat2; double b = Math.toRadians(lng1) - Math.toRadians(lng2); double s = 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2))); return s * EARTH_RADIUS_KM; }6371.0 是地球平均半径,单位是千米,所以返回结果也以千米为单位。Haversine 公式在几十公里范围内的误差可以忽略,做商家距离排序完全够用。需要注意入参纬度顺序别写反了,lat 和 lng 不对应的话算出来的距离会完全失真。
拿到用户当前位置后,对商家的距离排序就是在列表内存里做一次比较器排序:
Collections.sort(merchantList, new Comparator<Merchant>() { @Override public int compare(Merchant m1, Merchant m2) { double d1 = GeoUtil.distanceKm(myLat, myLng, m1.getLat(), m1.getLng()); double d2 = GeoUtil.distanceKm(myLat, myLng, m2.getLat(), m2.getLng()); return Double.compare(d1, d2); } });这种全量排序在商家数不多时可读性很好。如果商家量很大,更稳的做法是在 SQL 里用ORDER BY (lat - ?) * (lat - ?) + (lng - ?) * (lng - ?)先粗排,再对前 100 条精确计算距离。另外定位失败时 myLat/myLng 可能是 0,需要先做一次是否为 0 的判定,避免把所有距离算成 111 公里左右的奇怪值。
3.3 详情页与图片加载:Glide 参数与列表复用问题
商家详情页是典型的复杂页面:顶部商家图、基本信息、点评列表、推荐菜。这张页面如果直接塞一个 RecyclerView,四种不同类型的 item 塞进同一个 adapter,代码会越写越乱。源码里的方案是 ScrollView + 不可滚动 ListView,用最少代码完成拼接:
ListView reviewList = findViewById(R.id.review_list); reviewList.setNestedScrollingEnabled(false); reviewList.setAdapter(reviewAdapter);ListView 被套进 ScrollView 后会抢占手势,所以要用 setNestedScrollingEnabled(false) 禁掉它自身的滚动,让外层 ScrollView 统一接管滑动。外层 ScrollView 负责整个页面手势,ListView 高度自然撑开到全部内容,这就是详情页不卡顿的关键。点评数据少时这套方案没问题,如果点评超过几十条,建议还是换 RecyclerView + 多类型 item 或者 HeaderView,那样滚动性能会更好。
图片加载统一走 Glide,参数写得比较齐全:
Glide.with(imageView.getContext()) .load(merchant.getImageUrl()) .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_error) .skipMemoryCache(false) .diskCacheStrategy(DiskCacheStrategy.ALL) .into(imageView);placeholder 是加载中的占位图,error 是加载失败时显示的兜底图,这两个参数在列表页特别重要,没有占位图的话快速滑动时会看到黑色闪块。diskCacheStrategy 用 ALL 表示远程图片的原图和缩放图都缓存到磁盘,下次加载同一张图直接读磁盘,省流量也快。skipMemoryCache(false) 是默认行为,显式写出来是提醒自己这个参数在列表频繁滑动时不要改成 true,否则滑动一次请求一次网络。
3.4 下单状态机:枚举定义与非法流转拦截
订单状态是这套工程里业务规则最集中的地方。如果只靠一个 int 字段加任意赋值,很容易状态混乱。源码用枚举做了状态机约束,定义和校验逻辑如下:
public enum OrderStatus { UNPAID(0), PAID(1), COMPLETED(2), CANCELED(3); private final int value; OrderStatus(int value) { this.value = value; } public int getValue() { return value; } public boolean canTransitTo(OrderStatus target) { switch (this) { case UNPAID: return target == PAID || target == CANCELED; case PAID: return target == COMPLETED; default: return false; } } }状态流转规则是:待支付可以取消,也可以去支付;已支付之后只能到已完成;已完成和已取消都是终态,不能再变。每次订单状态变更前,先调用一次 canTransitTo 做校验:
OrderStatus current = OrderStatus.UNPAID; if (!current.canTransitTo(OrderStatus.PAID)) { throw new IllegalStateException("非法状态流转: " + current); } current = OrderStatus.PAID;这种写法把业务规则收敛到枚举里,比散落在各个按钮点击事件里的 if-else 好维护得多。改需求时只需要动枚举里的 canTransitTo 方法,不会出现改了支付页面忘了改取消页面导致的状态不一致问题。源码里 orders 表的 status 默认是 0,对应枚举的 UNPAID,状态值传参时用 getValue() 就完成了枚举和 int 字段的映射。
4. 避坑与排查:模拟器闪退、数据库迁移与本地接口联调的五个翻车记录
4.1 现象:X86 模拟器打开即闪退,Logcat 指向 lib/ 下的 so 文件
第一次在电脑上跑这套工程,我建了一个 X86 架构的模拟器,安装完 APK 一点就闪退。Logcat 反复出现library "xxx.so" not found的报错。这类工程里常见的第三方 SDK 只打包了 ARM 架构的 so 文件,X86 模拟器运行时会因为找不到对应指令集的 so 而崩溃。解决方法是换一个 ARM64 架构的模拟器镜像,或者在 build.gradle 的 defaultConfig 里加上abiFilters 'arm64-v8a',让构建时只打包 ARM 架构库,X86 模拟器走 ARM 翻译运行。如果项目里已经集成了地图、支付这类重 SDK,多半是这个问题。
4.2 现象:升级 App 后筛选页崩溃,报错 no such column
老用户手机里装了旧版本,再装新版本后一点商圈筛选就闪退,错误信息是no such column: business_area。原因很明确:旧的数据库文件里没有新加的字段,而 DBHelper 的 onUpgrade 方法是空的,没有执行 ALTER TABLE 迁移语句。调试阶段最简单的做法是卸载 App 重装,清掉旧库重新建表;如果用户数据不能丢,必须在 onUpgrade 里写迁移逻辑:
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE merchant ADD COLUMN business_area TEXT"); } }旧版本为 1、新版本为 2 时执行这一条,以后每次升级只追加对应的版本判断。最怕的是直接把建表语句在 onUpgrade 里 DROP TABLE 重建,用户评论和订单数据就全没了。从这套源码头一回改表结构时,我就在这上面吃过亏。
4.3 现象:详情页滑到点评列表时图片疯狂闪烁
详情页上下滑动时,点评区的用户头像偶尔会闪一下白底再出来。原因是 ListView 的 item 复用时没有开稳定 ID,notifyDataSetChanged 之后 item imageView 被复用到别的位置,回程的图片请求把旧图覆盖了。解决方法是给 Adapter 开setHasStableIds(true),并在 getItemId 里返回数据行的唯一 ID,让 ListView 知道每个 item 的身份而非位置。配合 Glide 的 diskCacheStrategy 缓存策略,图片闪烁问题基本就消失了。如果列表是 RecyclerView,则要检查 DiffUtil 是否配置了正确的 areContentsTheSame 回调。
4.4 现象:真机联调时接口全部超时,模拟器却一切正常
代码在模拟器上跑得好好的,换到真机后所有请求都超时。这个问题的根源是模拟器访问宿主机用的是10.0.2.2,而真机必须访问电脑在局域网里的实际 IP。把 ApiClient 的 baseUrl 从http://10.0.2.2:8080改成http://192.168.x.x:8080后能通,但这要求后端服务监听 0.0.0.0 而不是 127.0.0.1。更省事的方式是用adb reverse tcp:8080 tcp:8080,把真机的 8080 端口转发到电脑本地,baseUrl 继续写 localhost 就行,不用改代码也不用记局域网 IP。
4.5 现象:Glide 加载 HTTPS 图片时显示 error 占位图
商家图片切换到 HTTPS 地址后,Glide 一直走 error 分支。第一步要确认 SSL 证书是否有效,自签名证书在 Android 上默认不通过校验;第二步看 targetSdkVersion 是否大于等于 28,这个版本开始默认禁止明文 HTTP 明文流量,如果图片地址还是 HTTP,需要在 network_security_config.xml 里对指定域名放行明文,或者干脆把所有图片地址统一改成 HTTPS。如果是自签名证书,则要在 OkHttp 里自定义信任管理器,这条不建议跳过,直接用全局忽略证书的方案在评审时容易被拦。
这五条不是偶发问题,几乎是拿到这套源码后必走的弯路。前两条在「首次运行、升级安装」阶段出现,后两条在「模拟器转真机、本地图片转正式图片」阶段出现,按顺序排查能省很多时间。
5. 进阶改造:把 SQLite 数据访问层替换成可插拔的后端接口
5.1 定义数据源抽象:IDataSource 接口与工厂切换
这套源码的底层数据源是 SQLite,但很多场景需要换成真实后端接口。如果直接改 Activity 里的 DAO 调用,后面接口文档一变又要全局改。更稳的做法是给数据访问层加一层抽象,让页面只依赖接口,不依赖具体实现。
public interface IDataSource { List<Merchant> queryMerchants(String keyword, String area, String sortType); Merchant getMerchantById(int id); boolean createOrder(Order order); }本地数据源 LocalDataSource 内部继续走原来的 DAO 和 GeoUtil 排序,远程数据源 RemoteDataSource 则把方法参数拼成请求参数,调用 ApiClient 后解析 JSON。页面层的 Activity/Fragment 完全不感知数据从哪来。再给一个工厂方法,用 BuildConfig 开关决定当前编译用哪套数据源:
public class DataSourceFactory { public static IDataSource create() { if (BuildConfig.USE_REMOTE) { return new RemoteDataSource(); } return new LocalDataSource(); } }BuildConfig.USE_REMOTE 在 build.gradle 里配置为 buildConfigField,调试包设 true,发布包设 false,这样打包时自动切换,不用手动改代码。从这之后,页面列表只需要调dataSource.queryMerchants(...),拿到 List 直接 setData,后面改接口、加字段都只动 RemoteDataSource 一个类。
5.2 用 JSON 服务做联调验证:最快跑通一条业务链路
改造完成后要验证逻辑,推荐先用 JSON 服务模拟后端接口,不用等后端就绪。建一个 db.json 文件放商家和评论数据,一条命令起本地服务:
npx json-server --watch db.json --port 8080然后把 RemoteDataSource 的 baseUrl 指向模拟器地址http://10.0.2.2:8080,跑一遍「搜索关键词 → 点进商家详情 → 查看点评 → 创建订单」全链路。这个过程中重点观察三点:第一,商家列表 JSON 字段名和 Merchant 类字段名是否一致,不一致时 Gson 会静默赋默认值;第二,订单创建接口回包里的 status 是否能正确映射到 OrderStatus 枚举;第三,错误响应码是否能在 UI 层给出可读提示。这一步走通了,后面接真实后端就只是一个改 IP 和适配字段名的工作。
我拿到这套源码后犯过的一个实际错误是:先在 Activity 里直接调用 SQLiteDAO 把功能跑通,然后收到后端接口文档后发现所有调用点都要改,打车回家才想明白一开始就该加一层 IDataSource 接口。从那以后,我每次拆带本地数据库的源码,都强制自己先做数据源抽象再碰界面。这个习惯让后面每次换存储方案都不需要碰业务层,希望帮到你。
本文还有配套的精品资源,点击获取