反编译APK:拆解Android酒店预订系统源码与业务逻辑
简介一份基于Android平台的酒店预订系统源码面向移动开发学习者与求职者用于理解完整预订类App的工程结构。项目涵盖用户注册登录、酒店搜索与筛选、列表与地图展示、详情页、日期选择、预订下单、支付集成、订单管理、评价反馈、通知提醒、后台管理等模块覆盖从业务需求到技术实现的完整开发流程。压缩包共169个文件大小2.61MB主要包括Java源码、XML布局与配置、图片素材、APK安装包及编译中间文件整体结构清晰便于对照运行效果理解代码逻辑。已有570人学习下载。通过学习可掌握Android网络请求、JSON/XML解析、数据库操作、自定义UI、第三方支付与消息推送等实战技能配套readme和界面截图可快速上手适合毕业设计、课程实训、面试项目展示与二次开发也能锻炼移动端项目架构能力。1. 从 SummerHotel.apk 拆出一个酒店预订系统的正确姿势一份名为「Android 酒店预订系统源码.rar」的压缩包解压后看到的不是标准 Android Studio 工程而是一堆散装文件resources.ap_、SummerHotel.apk、room.bmp、MainActivity.class、MainActivity$1.class、MainActivity$31.class、MainActivity$36.class、HttpUploadUtil.class。看到 MainActivity$31、MainActivity$36 这种命名基本可以断定这不是一个能直接 sync 的工程而是从编译产物里翻出来的「考古现场」。读它的正确方式是把它当成反编译对象从 class 文件名推断模块边界从资源索引找 UI 结构再结合 APK 还原完整的预订业务流程。适合正在做课程设计、需要短平快出功能的人也适合想了解老式 Android 工程真实结构的从业者。下面按我拆这类包的习惯走一遍你会看到 36 个匿名内部类是怎么撑起一个完整预订流程的。2. 从类文件清单反推模块边界:MainActivity 内部类与 R 资源索引2.1 用 jadx 还原 MainActivity 的主线拿到 SummerHotel.apk先不要急着丢进 Android Studio。rar 里缺了 gradle 工程结构直接把.class文件拖进 IDE 是看不到完整代码的。我一般先在临时目录里解包再用 jadx 做反编译把 dex 还原成人能读的 Java 代码。mkdir -p ~/hotel_src cd ~/hotel_src unzip -q SummerHotel.apk -d apk_out jadx -d jadx_out apk_out/classes.dex逻辑说明unzip是为了拿到 APK 内部的 AndroidManifest.xml、resources.arsc 和 classes.dexjadx 会把 dex 里的字节码反编译成 Java 源码输出到jadx_out目录。参数说明-d指定反编译结果输出目录如果 APK 是多 dex 结构apk_out下会出现 classes2.dex、classes3.dex需要逐个处理或者直接jadx -d jadx_out apk_out/*.dex一次搞定。解压 rar 这一步Windows 上建议用 7-Zip 而不是 WinRAR 自解压到中文路径否则后面 aapt 和 adb 处理文件时容易踩编码坑。反编译出来后优先看两个文件MainActivity.java和AndroidManifest.xml。前者决定业务主流程后者决定入口 Activity、权限声明和包名。如果 jadx 输出里的 MainActivity 已经超过 1000 行不用惊讶——它内部塞了 36 个匿名内部类从登录到下单全在一个文件里。这就是为什么从 class 文件列表就能预判代码质量。2.2 内部类编号与职责映射javac 对匿名内部类的命名规则很简单外部类名$编号编号按源码中出现的顺序从 1 递增。MainActivity$1.class 就是 MainActivity 里第一个匿名内部类MainActivity$31 是第 31 个。看到这种命名可以先建立一张映射表再对照反编译代码验证。类文件推断职责对应业务MainActivity.class主界面控制器登录、酒店列表、预订入口MainActivity$1.class第一个匿名内部类常见于 setOnClickListenerMainActivity$31.class网络回调或线程回调订单提交后的状态处理MainActivity$36.class最后一个匿名回调支付或通知相关的收尾逻辑MyConverter.class适配器转换器酒店 item 数据与 View 绑定HttpUploadUtil.classHTTP 上传封装提交订单 JSON 数据R$id.class资源索引布局控件 id 的字节码形式这张表的经验依据是如果一个类的名字以$数字结尾且数字很大基本可以断定它属于某个大 Activity 的匿名内部类而不是独立业务类。独立类会直接命名为HotelAdapter、OrderManager之类。反编译后如果 MainActivity.java 里确实存在几十个new Callback(){...}说明团队把所有逻辑都堆在 Activity 里这是老项目的典型写法也是新接手的人最头疼的起点。拿到这种代码重构顺序一般是先把 MainActivity 里的列表展示抽成 Adapter再把网络请求抽到单独的管理类最后把支付回调抽出独立类。MyConverter 在清单里单独存在说明这个工程至少意识到列表绑定应该独立只是没有完全拆干净。2.3 resources.ap_ 与 R 索引的查表关系resources.ap_ 是 aapt 编译资源后的中间产物最终被合并进 SummerHotel.apk。R$id.class 里的每个静态 int 字段都指向 resources.arsc 里某个资源项的索引。这块值得展开讲一下因为很多人以为 R.id 是内存地址其实不是它是一个 32 位整数格式为 0xPPTTEEEE其中 PP 是包 IDTT 是资源类型EEEE 是资源表中的条目序号。用 aapt 可以直观看到这个索引aapt dump resources SummerHotel.apk | grep -E room|layout|string | head -30逻辑说明这条命令把 APK 里的资源表按类型打印出来再筛掉无关内容。room对应 room.bmp 的条目layout对应各布局文件的引用。参数说明aapt 在 Android SDK 的 build-tools 目录下如果提示找不到用$ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | tail -1)/aapt拼出完整路径执行。注意一个细节room.bmp在 rar 里是根目录文件而不是 res/drawable 下的资源。这说明它可能走的是 assets 或直接读写文件路径的方式加载而不是R.raw.room。如果反编译后代码里出现getAssets().open(room.bmp)那就印证了 assets 方案如果出现BitmapFactory.decodeFile(room.bmp)则是文件路径方案。这两种加载方式在内存和路径处理上有区别第 3 章会展开。3. 列表渲染与房间图片:MyConverter 和 room.bmp 的实际用法3.1 MyConverter 完成 ViewHolder 复用关于 MyConverter从类名本身无法确定它是 UI 绑定还是 JSON 转换器。我拿到这类包时第一件事是搜代码里new MyConverter的出现位置在listView.setAdapter附近就是 View 绑定逻辑在Gson.fromJson前后就是对象映射。按这个 rar 里 MainActivity 与 MyConverter 的相邻关系以及没有第三方 JSON 库的迹象我倾向于它是列表 item 的绑定类。如果后续反编译发现不同再按实际定位调整即可。针对 UI 绑定场景常见做法是让 MyConverter 承担getView里的数据绑定职责把 convertView 的判断和 Tag 缓存逻辑收拢到一起。示例代码public class MyConverter { private LayoutInflater inflater; private int layoutResId; public MyConverter(Context context, int layoutResId) { this.inflater LayoutInflater.from(context); this.layoutResId layoutResId; } public View bindView(int position, View convertView, ViewGroup parent, HotelItem hotel) { ViewHolder holder; if (convertView null) { convertView inflater.inflate(layoutResId, parent, false); holder new ViewHolder(convertView); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } holder.nameView.setText(hotel.getName()); holder.priceView.setText(¥ hotel.getPrice()); if (hotel.getBitmap() ! null) { holder.imageView.setImageBitmap(hotel.getBitmap()); } return convertView; } }逻辑说明bindView内部先判断 convertView 是否为空为空才 inflate 新布局非空时直接复用 Tag 中缓存的控件引用从而避免每次滚动都重新查找控件。参数说明position是数据源下标convertView是 ListView 提供的可复用视图parent是父容器hotel是当前条目的数据模型。如果项目用的是 RecyclerView这套逻辑就对应onCreateViewHolder和onBindViewHolder两个方法职责不变。3.2 room.bmp 的采样加载与回收边界room.bmp 这种位图如果放在 assets 目录代码里不能直接用文件路径得通过 AssetManager 打开输入流如果放在 res/raw则对应R.raw.room。这个包把位图散在 rar 根目录说明可能是从设计稿里拷出来的原始素材还没归位到 res 目录。手动加载位图时最容易踩的坑是一次性把整张图解码进内存导致大图 OOM。BitmapFactory.Options opts new BitmapFactory.Options(); opts.inJustDecodeBounds true; BitmapFactory.decodeFile(room.bmp, opts); int sample 1; while (opts.outWidth / sample 720 || opts.outHeight / sample 480) { sample * 2; } opts.inSampleSize sample; opts.inJustDecodeBounds false; Bitmap room BitmapFactory.decodeFile(room.bmp, opts);逻辑说明第一次 decode 时inJustDecodeBounds true只读取图片宽高不加载像素根据目标显示尺寸计算出采样率后再真正解码减少内存占用。参数说明inSampleSize取 2 的幂时解码效率最高系统会按 1/2、1/4、1/8 的比例降采样inJustDecodeBounds第二次必须设为 false否则拿不到实际像素数据。这里有个 5 年以上经验的人也常忽略的点Android 4.0 之后不要轻易手动调用Bitmap.recycle()。早期版本内存碎片严重recycle 能及时释放 Native 层内存但后来的系统已经接管了位图生命周期手动 recycle 反而可能在图片仍被 View 引用时触发 use-after-free 的崩溃。正确做法是让位图跟随 Activity 生命周期由 GC 统一回收。如果酒店列表的图片数量大最好引入 Glide 这类图片库直接用Glide.with(context).load(roomFile).into(imageView)省去采样率计算的麻烦。提示如果反编译发现房间图是用 ImageView 直接 setImageBitmap 实现且没有做缩略图处理建议在重构时补上采样加载否则在低端真机上酒店列表滑动会明显卡顿。4. 预订提交链路:HttpUploadUtil 的封装与匿名回调分发4.1 HttpURLConnection 提交订单 JSONHttpUploadUtil 这个类名直白负责上传多半是提交订单数据或用户信息。老工程里不用 OkHttp而是用 HttpURLConnection 手写网络层的情况非常普遍。我一般会把它封装成静态方法接收 URL 和 JSON 字符串回调里返回结果或错误信息。public class HttpUploadUtil { private static final int CONNECT_TIMEOUT 10_000; private static final int READ_TIMEOUT 15_000; public static void submitOrder(String url, String orderJson, Callback cb) { new Thread(() - { HttpURLConnection conn null; try { conn (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod(POST); conn.setDoOutput(true); conn.setRequestProperty(Content-Type, application/json;charsetUTF-8); conn.setConnectTimeout(CONNECT_TIMEOUT); conn.setReadTimeout(READ_TIMEOUT); OutputStream os conn.getOutputStream(); os.write(orderJson.getBytes(UTF-8)); os.flush(); os.close(); int code conn.getResponseCode(); if (code 200) { cb.onSuccess(readStream(conn.getInputStream())); } else { cb.onError(HTTP code); } } catch (Exception e) { cb.onError(e.getMessage()); } finally { if (conn ! null) conn.disconnect(); } }).start(); } }逻辑说明submitOrder把网络请求放到子线程执行避免阻塞主线程请求头指定 JSON 格式服务端才能正确解析请求体响应码 200 时读取输入流并回调成功否则把错误码透传给调用方。参数说明CONNECT_TIMEOUT是建立连接的超时时间READ_TIMEOUT是等待服务端响应的超时时间单位都是毫秒orderJson的内容一般是{hotelId:12,roomType:大床房,checkIn:2025-06-01,checkOut:2025-06-03}这样的结构。如果接口要求表单格式Content-Type 要改成application/x-www-form-urlencoded参数用 URLEncoder 编码后拼接。4.2 回调分发与 MainActivity$31 的由来这段代码里new Callback(){...}javac 会自动生成MainActivity$31.class之类的文件。看到 MainActivity$31、MainActivity$36 存在并不是说项目有 36 个功能类而是 MainActivity 里写了至少 36 个匿名内部类。这是拆源码时最有效的判断信号——它告诉你这个 Activity 到底膨胀到了什么程度。以订单提交为例回调里更新 UI 必须切回主线程HttpUploadUtil.submitOrder(http://api.example.com/order, orderJson, new Callback() { Override public void onSuccess(String result) { runOnUiThread(() - { tvStatus.setText(预订成功订单号 result); btnSubmit.setEnabled(false); }); } Override public void onError(String msg) { runOnUiThread(() - Toast.makeText(MainActivity.this, 失败 msg, Toast.LENGTH_LONG).show()); } });逻辑说明runOnUiThread把 UI 更新操作放到主线程队列执行是 Activity 自带的方法比handler.post更简洁成功时显示订单号并禁用提交按钮失败时用 Toast 提示。参数说明result是服务端返回的订单号字符串实际项目中通常是一个 JSON 片段需要解析后取值MainActivity.this是用在匿名内部类里时对外部 Activity 实例的显式引用避免 this 指向错误。这种把 36 个回调全部堆在 Activity 里的写法问题很明显难以单元测试稍微改一个回调就可能影响其他逻辑。我拆到这一步时会先把所有网络回调抽成独立的 Callback 类至少让编译器不再为每次点击生成一个新的 class 文件。4.3 入住天数与节假日价格调整预订流程里绕不开的一个逻辑是计算入住天数而且必须按自然日算。如果直接拿两个时间戳相减除以 86400000遇到夏令时或者不同时区会少算或多算一天。常见做法是用 TimeZone 修正偏移量private long calcNights(long checkInMillis, long checkOutMillis) { TimeZone tz TimeZone.getDefault(); long day 24L * 60 * 60 * 1000; long start (checkInMillis tz.getOffset(checkInMillis)) / day; long end (checkOutMillis tz.getOffset(checkOutMillis)) / day; return Math.max(0, end - start); }逻辑说明getOffset返回该时刻相对 UTC 的毫秒偏移量先加上再整除得到的是当地时间对应的「日序号」两个日序号相减就是自然日间隔。参数说明checkInMillis是入住日 0 点的毫秒戳checkOutMillis是离店日 0 点的毫秒戳返回值是入住的晚数最少为 0。节假日价格调整一般不在算法里写死而是维护一张日期价格表比如2025-10-01: 688预订时按日期区间匹配。源码包里如果看到写死的if (month 10 day 1)说明是课程设计级别生产环境不能这么干。5. 真机运行与三处必改的兼容配置5.1 三条命令快速验证源码包拿到 SummerHotel.apk 之后我建议按下面三步走每一步都能验证一个问题aapt dump badging SummerHotel.apk | head -20 adb install -r SummerHotel.apk jadx -d out SummerHotel.apk逻辑说明第一条命令查看 APK 的包名、版本号、权限声明确认它不是模拟器专属包第二条命令把 APK 覆盖安装到已连接的设备-r表示保留已安装应用的数据第三条命令反编译出 Java 代码用于定位问题和改逻辑。参数说明head -20只打印前 20 行避免资源表太长刷屏adb需要先连接真机或模拟器连接后用adb devices确认设备在线。5.2 明文 HTTP 与网络权限适配老源码最大的兼容性问题不是代码过时而是没有适配 Android 9 之后的明文流量限制。这个项目里 HttpUploadUtil 走的是http://明文地址Android 9 及以上默认禁止明文流量直接跑会抛CLEARTEXT communication not permitted。解决办法是在 Manifest 里开启明文流量application android:usesCleartextTraffictrue android:networkSecurityConfigxml/network_security_config /application如果只想针对特定域名放开用 networkSecurityConfig 更精细network-security-config base-config cleartextTrafficPermittedfalse trust-anchors certificates srcsystem/ /trust-anchors /base-config domain-config cleartextTrafficPermittedtrue domain includeSubdomainstrueapi.example.com/domain /domain-config /network-security-config逻辑说明usesCleartextTraffic是全局开关直接设为 true 最省事但不够安全network-security-config可以精确控制哪些域名允许明文适合后端接口还没上 HTTPS 的过渡期。参数说明includeSubdomains表示该域名的所有子域也生效certificates srcsystem/表示信任系统内置证书。手动把 room.bmp 塞回 assets 目录后记得检查加载路径与 Manifest 里的权限是否匹配否则列表页会先白屏再崩溃。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →