Java Material Design 图文客户端架构拆解与避坑实战

简介:NHBooks 是一套基于 Java 开发的 Android 客户端源码,采用 Material Design 设计规范,面向希望研究 Android 应用结构、界面布局与网络数据解析的开发者。项目通过 API 与 Jsoup 解析获取内容,提供轻量浏览界面,可作为 Material Design 风格客户端、Android 工程组织及 HTML 解析实践的参考案例。压缩包共 163 个文件,约 3.2MB,包含 62 个 Java 源码、38 个 XML 布局与配置、42 个 PNG 图片资源,以及 4 个 Gradle 构建脚本和 APK 等文件,目录覆盖源码、资源、构建配置与可安装包,便于整体阅读和编译研究。已有 3189 人学习,说明其在 Android 客户端学习场景中具有一定参考价值。读者可从中了解界面资源组织、网络请求与解析流程、Gradle 构建配置及成品打包方式;需注意内容不适合未成年人,使用时应遵守当地法律法规。

1. 一个用 Java 写透的 Material Design 客户端,先看清它能干什么

界面丑、滚动卡、翻页白屏——这是大部分人第一次跑这类图文浏览客户端时的三个印象,而这份工程恰好把这三件事当作主线来解。它是纯 Java 写的,没有 Kotlin、没有 Compose,全套 Activity + Fragment + MVP,UI 层按 Material Design 规范铺开:底部导航、可折叠标题栏、卡片式列表、沉浸式状态栏一样不少。对拿它练手的开发者来说,价值不在于「又一个看图 App」,而在于它把老派 Java 工程里最容易散架的四块——网络层、分页列表、图片缓存、主题切换——用一套能读通的代码串了起来。想补 Android 基础架构、又不想被 Kotlin 协程和 Jetpack 全家桶绕晕的人,拿它当拆解样本正合适。

2. 工程骨架与依赖分层:从 build.gradle 到包结构的一次摸底

一个 Android 工程好不好改,看两处就知道:依赖清单干不干净,包结构有没有边界。这份工程在这两点上都属于「老派但规矩」的类型——依赖没有乱塞,包按功能切,不按类型切。

2.1 Gradle 依赖清单与版本约束

打开 app 模块的 build.gradle,依赖大致可以分成四组:网络、图片、UI 组件、调试。这种老工程不会用 Version Catalog,全靠手写版本号,所以第一件事是看清哪些版本互相咬死。

android { compileSdkVersion 28 defaultConfig { applicationId "com.example.galleryclient" minSdkVersion 21 targetSdkVersion 28 versionCode 1 versionName "1.0" } compileOptions { // 老工程常见的坑:不开这个,Lambda 和部分三方库会直接编译失败 sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { // 网络三件套 implementation 'com.squareup.retrofit2:retrofit:2.6.2' implementation 'com.squareup.retrofit2:converter-gson:2.6.2' implementation 'com.squareup.okhttp3:logging-interceptor:3.12.1' // HTML 兜底解析,接口挂掉时用得上 implementation 'org.jsoup:jsoup:1.12.1' // 图片 implementation 'com.github.bumptech.glide:glide:4.9.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0' // Material 组件与列表 implementation 'com.google.android.material:material:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'androidx.cardview:cardview:1.0.0' // 视图绑定与事件 implementation 'com.jakewharton:butterknife:10.2.0' annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.0' implementation 'org.greenrobot:eventbus:3.1.1' }

这段清单里有两个点值得单独说。一是compileOptions里的 Java 8 支持,很多人在 Android Studio 4.x 上直接打开老工程,第一屏就是 Lambda 报红,原因就在这里。二是 ButterKnife 这类注解处理器必须同时写implementation和annotationProcessor两行,只写一行会在运行时拿到空 View,报 NullPointerException 却不告诉你为什么。

依赖顺序本身不影响编译,但影响你排查问题的顺序。我一般的习惯是先确认网络三件套版本一致(Retrofit 和 converter 必须是同一版本号),再确认 Glide 的 compiler 和主库版本一致,最后才看 UI 库。因为这三组里任何一组版本错位,报错信息都会指向一个跟真实原因无关的类。

另外注意minSdkVersion 21。这个值决定了你能不能直接用MaterialComponents主题的部分属性,也决定了图片加载库要不要开兼容模式。改这个值之前先想清楚,降下去容易,降下去之后再补兼容代码就是另一回事了。

2.2 包结构与 MVP 的分层边界

包结构是判断一个工程「能不能二次开发」的最快方式。这份工程按功能切包,每个功能包里再按 MVP 分三层,整体长这样:

com.example.galleryclient ├── base // BaseActivity、BasePresenter、BaseView ├── data // 数据层 │ ├── api // Retrofit 接口定义 │ ├── model // 实体类 │ ├── parser // Jsoup 解析器(兜底通道) │ └── repository // 仓库,统一收口网络与本地缓存 ├── ui │ ├── main // 主界面 + 底部导航 │ ├── list // 列表页 │ ├── detail // 详情页 │ └── settings // 设置页 ├── widget // 自定义 View、适配器基类 └── util // 工具类

这个结构里最关键的是data/repository这一层。很多新手写 MVP 会把 Retrofit 调用直接塞进 Presenter,结果是换一个数据源就要改一遍业务代码。加了 Repository 之后,Presenter 只认接口不认实现,网络通道和本地缓存通道可以互换,甚至可以在 Repository 里做「先读缓存再请求网络」的组合逻辑,Presenter 完全无感。

base包里通常放三个东西:BaseActivity 负责统一的状态栏和权限处理,BasePresenter 持有 View 的弱引用防止内存泄漏,BaseView 定义 loading / error / empty 三个状态的通用回调。这三件套写好了,后面每个页面能省掉大量重复代码。

2.3 Application 里的全局初始化

Application 是这类工程的「总开关」,几乎所有全局配置都从这里出发。看一个工程的 Application 类,基本能推断出它的运行时行为。

public class App extends Application { private static App instance; private OkHttpClient okHttpClient; @Override public void onCreate() { super.onCreate(); instance = this; // 1. 全局异常兜底,方便定位线上崩溃 Thread.setDefaultUncaughtExceptionHandler((t, e) -> { Log.e("App", "uncaught in " + t.getName(), e); }); // 2. OkHttp 单例,避免每个 Retrofit 实例各建一套连接池 okHttpClient = new OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .build(); // 3. 主题模式在启动时就要定下来,晚于 setContentView 就来不及了 ThemeManager.applySavedTheme(this); } public static App get() { return instance; } public OkHttpClient httpClient() { return okHttpClient; } }

这里的顺序不能乱。异常兜底放最前,是为了让后面所有初始化出错时都能被捕获到;OkHttp 单例必须在任何 Retrofit 构建之前完成,否则你会在日志里看到一堆独立的连接池在互相抢资源;主题应用必须早于任何 Activity 的setContentView,晚一步就会出现「启动瞬间白屏再变黑」的闪烁。

ThemeManager.applySavedTheme是个自定义类,内部读 SharedPreferences 里存的模式标记,再调用AppCompatDelegate.setDefaultNightMode。这行代码看起来平平无奇,但它和后面的 Activity 重建逻辑强耦合,第 4 章会展开讲。

3. 网络与数据链路:一次列表请求从 OkHttp 到 RecyclerView 的完整路径

列表页是这个客户端的主战场,也是最能看出工程质量的地方。一次翻页请求要经过拦截器、接口定义、Repository 分发、Presenter 状态机、适配器增量刷新五个环节,任何一环处理不当,用户看到的就是卡顿或者重复数据。

3.1 Retrofit 接口与 OkHttp 拦截器

接口定义本身没什么花活,关键是拦截器这一层。这类客户端通常要伪装请求头、要处理分页参数、要在调试时打印完整报文。

public interface GalleryApi { // 列表接口,page 从 1 开始 @GET("api/list") Call<GalleryListResponse> getList( @Query("page") int page, @Query("limit") int limit ); // 详情接口 @GET("api/detail") Call<GalleryDetailResponse> getDetail(@Query("id") String id); }
public class HeaderInterceptor implements Interceptor { private final String userAgent; public HeaderInterceptor(String userAgent) { this.userAgent = userAgent; } @Override public Response intercept(Chain chain) throws IOException { Request origin = chain.request(); Request wrapped = origin.newBuilder() // 固定 UA,服务端按 UA 返回不同结构时非常关键 .header("User-Agent", userAgent) .header("Accept", "application/json") // 加一个请求时间戳,排查缓存命中问题时有用 .header("X-Request-Time", String.valueOf(System.currentTimeMillis())) .build(); return chain.proceed(wrapped); } }

HeaderInterceptor里写死 UA 是有意为之。很多服务端会按 UA 判断返回 HTML 还是 JSON,UA 不对就会拿到一坨 HTML,Gson 解析直接抛JsonSyntaxException,而报错信息只会告诉你「Expected BEGIN_OBJECT but was STRING」,完全不提 UA 的事。这类问题我在早期排查上花过不少时间。

构建 Retrofit 实例时,把这些拦截器按顺序挂上去:

Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://api.example.com/") .client(App.get().httpClient().newBuilder() .addInterceptor(new HeaderInterceptor(UA)) .addInterceptor(new HttpLoggingInterceptor() .setLevel(HttpLoggingInterceptor.Level.BODY)) .build()) .addConverterFactory(GsonConverterFactory.create()) .build();

顺序上 HeaderInterceptor 要放在日志拦截器之前,这样日志里打印出来的就是最终发出去的报文,否则你看到的是「加了头之前」的样子,排查时会被误导。日志级别用BODY只适合调试,上线前记得收回到NONE,不然大图片的 base64 会把日志刷爆。

3.2 分页加载与适配器的增量刷新

分页列表的经典翻车点是「重复触发」。用户手指一抖,onScrolled连续回调好几次,每次都判定为「到底了」,于是同一页请求发了三遍,列表里出现三份一样的数据。

解决办法是给 Presenter 加一个闸门:

public class ListPresenter extends BasePresenter<ListView> { private int currentPage = 0; private boolean isLoading = false; private boolean hasMore = true; public void loadNextPage() { // 三重闸门:正在加载、没有更多、View 已销毁,任一成立就直接返回 if (isLoading || !hasMore || !isViewAttached()) { return; } isLoading = true; currentPage++; repository.fetchList(currentPage, 20, new Repository.Callback<GalleryListResponse>() { @Override public void onSuccess(GalleryListResponse data) { isLoading = false; if (data.getItems() == null || data.getItems().isEmpty()) { hasMore = false; getView().showNoMore(); return; } getView().appendItems(data.getItems()); // 服务端返回的总页数才是权威判断,别用「本页是否满 20 条」猜 if (currentPage >= data.getTotalPages()) { hasMore = false; } } @Override public void onError(Throwable t) { isLoading = false; currentPage--; // 失败要回退页码,否则会漏掉一页 getView().showError(t.getMessage()); } }); } }

isLoading这个标志位的赋值时机很讲究:必须在发起请求之前置 true,在回调里置 false。如果放到回调里才置 true,那么请求在途期间的所有重复调用都会穿过去。currentPage--这一行也别省,失败回退页码是保证「重试能补上」的前提,少了它,用户点重试就会跳过整整一页内容。

适配器这一侧,增量刷新要用notifyItemRangeInserted而不是notifyDataSetChanged。前者只会重绘新增区间,后者会让整个列表重新测量布局,几百条数据的时候卡顿肉眼可见。

public void appendItems(List<GalleryItem> newItems) { int start = items.size(); items.addAll(newItems); notifyItemRangeInserted(start, newItems.size()); }

参数start是插入起点,newItems.size()是插入数量,两个值必须和实际 addAll 的范围严格一致,否则会出现「列表跳动」或者「IndexOutOfBounds」这类玄学问题。

3.3 Glide 图片加载与三级缓存

图文客户端的流畅度,八成取决于图片加载。Glide 本身已经做了内存、磁盘、网络三级缓存,但默认配置不一定适合这个场景——列表里的缩略图和详情页的大图尺寸差异很大,用同一套缓存策略会互相挤占。

// 列表缩略图 Glide.with(holder.image) .load(item.getThumbUrl()) .placeholder(R.drawable.placeholder) .error(R.drawable.broken) .override(360, 480) // 固定解码尺寸,避免大图占内存 .diskCacheStrategy(DiskCacheStrategy.DATA) .into(holder.image); // 详情页原图 Glide.with(context) .load(detail.getOriginUrl()) .thumbnail(0.25f) // 先出 25% 缩略图,再换成原图 .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .into(imageView);

override(360, 480)是关键。不加这行,Glide 会按 ImageView 的实际尺寸解码,而列表项在滚动过程中尺寸可能还没测量完成,导致解码出来的图偏大,几十张图铺进去很快就把内存撑到 OOM。thumbnail(0.25f)则是详情页的体验优化:先加载低分辨率版本秒出,再异步替换成原图,用户感知到的就是「图一瞬间就有了」。

还有一个容易被忽略的点:Glide 的with()传什么对象决定了请求的生命周期。列表里传 ImageView 没问题,但在 Activity 销毁时如果有请求在途,没有正确绑定就会泄漏。规范做法是统一用Glide.with(fragment)或者Glide.with(activity),让 Glide 自己在合适的时机清理。

4. Material Design 落地:主题、可折叠标题栏与夜间模式

UI 层是这个项目的门面。Material Design 在 2019 年前后的落地方式跟现在不一样,那时候还没有 Material You 那套动态取色,全靠手写 colorPrimary、colorAccent 和主题继承。

4.1 主题色、字体与夜间模式切换

主题定义在res/values/styles.xml和res/values-night/styles.xml两份文件里,同名主题,不同配色。

<!-- values/styles.xml --> <style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar"> <item name="colorPrimary">@color/primary</item> <item name="colorPrimaryDark">@color/primary_dark</item> <item name="colorAccent">@color/accent</item> <item name="android:windowBackground">@color/bg_light</item> <item name="android:statusBarColor">@android:color/transparent</item> </style> <!-- values-night/styles.xml --> <style name="AppTheme" parent="Theme.MaterialComponents.NoActionBar"> <item name="colorPrimary">@color/primary_night</item> <item name="colorPrimaryDark">@color/primary_night</item> <item name="colorAccent">@color/accent_night</item> <item name="android:windowBackground">@color/bg_dark</item> <item name="android:statusBarColor">@android:color/transparent</item> </style>

两份文件必须用同一个 style name,系统会按当前模式自动挑。statusBarColor设成透明是配合后面沉浸式的,但如果不在 Activity 里手动处理,透明状态栏会让内容顶到屏幕最上面,与状态栏图标重叠。

夜间模式切换的核心是这一句:

AppCompatDelegate.setDefaultNightMode( isNight ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO);

调用之后当前 Activity 会自动重建,onCreate再走一遍,加载的就是另一份 styles。这带来一个必须处理的问题:重建会丢失页面状态。列表滚动位置、已加载的数据、输入框里的文字,全都回到初始值。

常见做法是在onSaveInstanceState里存滚动位置,重建后在数据回填完成时用layoutManager.scrollToPositionWithOffset恢复。数据本身一般不用存,因为 Repository 层有内存缓存,重建后重新请求会直接命中。这个细节不处理,用户切一次夜间模式就回到列表顶部,体验会非常割裂。

4.2 CoordinatorLayout 的滚动联动

详情页的头部是典型的 Material 结构:一张大图做背景,标题叠在上面,往上滚的时候标题栏收起,只留一个小标题栏。

<androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content"> <com.google.android.material.appbar.CollapsingToolbarLayout android:layout_width="match_parent" android:layout_height="280dp" app:layout_scrollFlags="scroll|exitUntilCollapsed" app:contentScrim="?attr/colorPrimary"> <ImageView android:id="@+id/header_image" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="centerCrop" app:layout_collapseMode="parallax" app:layout_collapseParallaxMultiplier="0.6" /> <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:layout_collapseMode="pin" /> </com.google.android.material.appbar.CollapsingToolbarLayout> </com.google.android.material.appbar.AppBarLayout> <androidx.core.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior"> <!-- 正文内容 --> </androidx.core.widget.NestedScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout>

三个属性决定最终效果。layout_scrollFlags="scroll|exitUntilCollapsed"表示 AppBar 跟随滚动移出、但保留折叠后的最小高度;layout_collapseMode="parallax"让头图以较慢速度上移,产生景深感,parallaxMultiplier控制这个比例,0.6 是我试过比较舒服的值,再大会显得头图「粘」在屏幕上;layout_collapseMode="pin"让 Toolbar 始终钉在顶部。

最容易翻车的是那个app:layout_behavior="@string/appbar_scrolling_view_behavior"。少了它,内容区不会跟着 AppBar 联动,滚动时两者各滚各的,视觉上就是两层内容叠在一起乱动。这个属性特别容易被复制粘贴时漏掉,因为漏了不报错,只是「效果不对」。

4.3 详情页转场与沉浸式状态栏

从列表点进详情,如果直接startActivity,视觉上是硬切。加上共享元素转场,图片会从列表位置「飞」到详情位置。

// 列表页发起 ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation( activity, holder.image, ViewCompat.getTransitionName(holder.image) // 两端 transitionName 必须一致 ); ActivityCompat.startActivity(activity, intent, options.toBundle());

两个 Activity 里的 ImageView 都要设android:transitionName,而且值必须相同。不同 Item 之间用同一个 transitionName 会有问题吗?在这个场景里不会,因为同一时刻只有一个 Item 在参与转场。

沉浸式状态栏则是另一套逻辑:

getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); getWindow().setStatusBarColor(Color.TRANSPARENT);

设置LAYOUT_FULLSCREEN之后,内容会延伸到状态栏下方,此时如果顶部是深色图片,状态栏图标(白色)能看清;如果是浅色内容,图标就糊了。处理方式是给根布局加android:fitsSystemWindows="true"让内容整体下移,或者在代码里用WindowInsets动态加 padding,后者更灵活但要注意兼容 API 21 以下——这份工程的minSdkVersion是 21,刚好在边界上。

5. 避坑与排查:跑起来之后最容易翻车的五个点

从 clone 到跑通,中间通常会有几次「明明代码没问题但就是不对」的时刻。下面这五条是我在同类工程上反复遇到的。

5.1 编译期与依赖相关

现象:打开工程后 Gradle sync 成功,但一编译就报Cannot resolve symbol 'R',或者 ButterKnife 的@BindView全部失效。

原因:annotationProcessor没配对,或者 AndroidX 与 support 库混用。老工程经常出现一半依赖是androidx.*、一半是android.support.*的情况,编译器不会直接告诉你冲突,只会让资源索引生成失败。

解决:在gradle.properties里加android.useAndroidX=true和android.enableJetifier=true,然后把所有com.android.support开头的依赖统一换成对应的androidx包。换完之后 Clean Project 再 Rebuild,不要只点 Run。

现象:Duplicate class com.google.common.util.concurrent.ListenableFuture之类的重复类报错。

原因:某个三方库传递依赖了一个旧版本的 guava,和你显式引入的版本打架。

解决:用./gradlew app:dependencies把依赖树打出来,找到重复的那一支,在build.gradle里加exclude group: 'com.google.guava',或者用resolutionStrategy.force强制统一版本。

5.2 运行时与渲染相关

现象:列表滑动时图片会「串位」,A 项的图跑到 B 项上。

原因:Glide 的请求绑定的是 ImageView 实例,RecyclerView 复用 ViewHolder 时,旧的请求还没结束就被新数据覆盖,回调回来时贴到了错误的条目上。

解决:在onBindViewHolder里,任何if分支导致不加载图片时,必须显式调用Glide.with(holder.image).clear(holder.image),把上一个请求清掉。只设置 placeholder 是不够的,placeholder 只在请求开始前显示,请求回来还是会覆盖。

现象:Jsoup 解析在某些设备上抛NetworkOnMainThreadException。

原因:Jsoup 的Jsoup.connect(url).get()是同步阻塞调用,被直接写在了回调或者点击事件里。

解决:包一层线程调度,Executors.newSingleThreadExecutor()或者 RxJava 的subscribeOn(Schedulers.io())都行,解析完成后再切回主线程更新 UI。注意线程池别每次调用都新建,用一个单例,否则并发解析时会出现多个连接同时打同一个地址。

现象:targetSdk 升到 28 之后,所有 http:// 开头的图片都加载不出来,日志里也没有明显报错。

原因:Android 9 起默认禁止明文流量。

解决:在AndroidManifest.xml的 application 节点加android:usesCleartextTraffic="true",或者更规范一点,配一个network_security_config.xml白名单,只对需要的域名放开。后者更安全,但要注意配置文件里写的是域名不是 IP,写错了不会报错,只会静默失效。

现象:分页加载到第三页之后,列表开始重复出现前面的内容。

原因:服务端页码从 0 开始,客户端从 1 开始,或者失败回退时页码没处理好,导致某一页被请求了两次而两次返回的数据都追加进去了。

解决:在 Presenter 里维护一个已加载页码的 Set,追加之前先判断这一页是否已经加载过;同时把currentPage的初始值和自增时机在注释里写清楚,这种「差一」的问题看代码是看不出来的,只能靠文档约束。

6. 二次开发:从改一个页面到加一套功能

真正开始改这个工程,建议从最小的切口进:先换一套配色和字体,跑通一次完整的「改样式—编译—看效果」循环,熟悉资源索引和主题继承的关系;再去改列表项布局,把卡片换成双列瀑布流,这一步会逼着你理解LayoutManager和ItemDecoration的配合;最后才动网络层和数据层。

改列表布局时,最省事的路径是替换LinearLayoutManager为StaggeredGridLayoutManager,同时给每个 Item 的图片高度设成不定值:

StaggeredGridLayoutManager lm = new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL); lm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_NONE); // 防止滚动时头部跳动 recyclerView.setLayoutManager(lm);

GAP_HANDLING_NONE这个参数值得记一下,默认策略会在滚动过程中不断重排以填补空隙,视觉上就是列表「抖一下」,关掉之后稳定很多,代价是首屏可能出现少量空白。

加新功能时,我一般按这个顺序推进,每一步都能独立验证:

步骤改动位置验证方式
加一个筛选入口主界面 Toolbar 菜单点开能弹出,选中后回调到 Presenter
把筛选条件带进请求GalleryApi 加 Query 参数抓日志确认参数拼进 URL
Repository 支持条件缓存缓存 key 加入筛选值切换条件后不串数据
列表支持条件刷新清空 items 后重新加载第一页不再触发重复请求

最容易出错的是第三步。缓存 key 如果只用了页码,切换筛选条件后就会命中上一个条件的数据,表现出来就是「切了筛选但列表没变」。这类 bug 在开发阶段如果不刻意验证,很容易带到用户手里。

还有一个我踩过的坑:这个工程用了 EventBus 做跨页面通信,设置页改完主题后post一个事件,主界面收到后重建。问题在于 EventBus 的订阅者如果在onStop之后没有unregister,事件会积压,下次进来一次性触发好几次重建,界面闪成一片。从那以后我每次写onStart里的register,都会强制在onStop里配一个对应的unregister,宁可多写一行也不留后悔药。这类工程改动量不大,但每一个都值得跑一遍完整回归——毕竟它最大的价值,是让你在一份能跑的代码上把 Android 那套老架构真正摸熟。希望帮到你。

本文还有配套的精品资源,点击获取