基于 Kotlin 实现一个简单的安卓新闻 App Demo。本文将完整描述工程化实现过程,有两个目标,一是学习并熟悉 Kotlin;二是对应用开发和架构迭代升级的一次简单复盘。为什么是复盘,而不直接写一篇关于架构迭代升级的文章呢?因为复盘可以更清楚的说明细节,说明为什么。是的,明白为什么很重要。也希望能对看到这篇文章的您有所帮助或启发。当然,如果发现有什么错误纰漏,还请各位大神不吝赐教,这里先谢过啦。
相关文章:
Kotlin 带你飞
相关 Github 源码
版权声明:本文为 frendy 原创文章,可以随意转载,但请务必在明确位置注明出处。
000.png
在前文《Kotlin 带你飞》中,笔者介绍了 Kotlin 的优势和环境搭建,并完成了简单的 Hello World。
接下来我们一起往更高阶进发。至于 Kotlin 的具体语法,如果您已经有过一些编程语言基础,笔者觉得大同小异,先简单浏览下,等具体用到什么就搜什么,就学什么,再顺带学学周边相关,也挺好,并没有必要刻意抽大段大段的时间学习,更何况 Android Studio 还提供了一键转换 Java 和 Kotlin 的工具是吧(菜单 Code -> Convert Java File to Kotlin File)。
当然,在这篇文章里,在复盘的过程中,笔者也会插入一些关于 Kotlin 的语法介绍和看法,因此前面的部分代码介绍会稍微多点,后面则会侧重应用开发和架构迭代升级的复盘和总结。
本文将实现以下功能。先简单总结下的话,前三部分是关于页面结构的简单复盘,第四部分是关于项目架构的简单复盘。
- Part-1 简单的新闻列表
- [1.1 实现一个列表,使用 RecyclerView]
- [1.2 实现网络请求]
- [1.3 实现图片加载]
- Part-2 多个频道和列表
- [2.1 第一次重构,支持多个频道和列表]
- [2.2 添加下拉刷新、上拉加载]
- Part-3 多个功能页
- [3.1 第二次重构,支持多个功能页]
- [3.2 实现闪屏页,使用 SharedPreferences 存储 uid]
- [3.3 实现详情页]
- Part-4 多个模块
- [4.1 第三次重构,支持多个模块]
- [4.2 谈谈架构]
Part-1 简单的新闻列表
查看相关源码 点这里 Demo v1.0.0
1.1 实现一个列表,使用 RecyclerView
使用 RecyclerView 实现一个简单的列表,并监听点击事件:
a) 布局 XML 跟以前一样写就 OK:
<android.support.v7.widget.RecyclerView
android:id="@+id/newsList"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
b) Activity 里添加代码如下,设置 LayoutManager,新建 Adapter 关联数据即可:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Kotlin 支持运算符重载,new 可以精简掉,也不用调用 setter 啦
newsList.layoutManager = LinearLayoutManager(mContext)
}
override fun onResume() {
super.onResume()
// 加载数据
loadNews()
}
private fun loadNews() = doAsync {
// 模拟数据,其中 val 类似于 final 的变量,如果后面需要可变可定义为 var
val list = mockData()
uiThread {
// 新建 Adapter 并实现点击监听处理,是不是精简得有点认不出来了
newsList.adapter = NewsListAdapter(list) {
toast(it.title)
}
}
}
private fun mockData(): ArrayList<News> {
val list = ArrayList<News>()
for(i in 0..20) {
// Kotlin 支撑字符串模板哦,简单的可以 $i,复杂点可以 ${user.name}
list.add(News("Test Title $i", "Test Desc $i"))
}
return list
}
c) 我们看看 Adapter 的实现:
class NewsListAdapter(val list: ArrayList<News>, val itemClickListener: (News) -> Unit): RecyclerView.Adapter<NewsListAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.news_item, parent, false)
return ViewHolder(view, itemClickListener)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(list[position])
}
// 看到这里,有没有觉得冒号挺万能,常量变量的类型声明,函数的返回值,类的继承都是冒号
override fun getItemCount(): Int = list.size
class ViewHolder(view: View, val itemClickListener: (News) -> Unit): RecyclerView.ViewHolder(view) {
fun bind(news: News) {
// with 方法接收一个对象和一个函数,这个函数会作为这个对象的扩展函数执行
with(news) {
itemView.title.text = news.title
itemView.desc.text = news.summary
itemView.setOnClickListener { itemClickListener(this) }
}
}
}
}
d) 有图有真相:
002.png1.2 实现网络请求
搭好列表框架,接下来需要填充真实的数据啦,这里笔者直接使用鲜闻公开的 API 接口(有需要的朋友可以通过微信公众号 frendy-share 留言联系我):
a) 搭建网络请求基础类,一个 run() 搞定是不是太简洁(捂脸):
class RequestCommon(val url: String) {
companion object {
// 伙伴对象,可以用于替代 Java 的 static 对象
val BASE_URL = "http://www.myxianwen.cn/newsapp/index.action?"
...
}
fun run(): String {
val jsonStr = URL(url).readText()
return jsonStr
}
}
b) 基于基础类,做一些业务接口封装,好吧,这个纯属个人习惯:
class Request(val context: Context, val gson: Gson = Gson()) {
fun init(): UserID {
val params: ReqInit = ReqInit(DeviceInfo.getAndroidID(context))
val url = RequestCommon.INIT_URL + gson.toJson(params)
val jsonStr = RequestCommon(url).run()
// 类名::class.java 没有为什么,就这么写,记着就是啦
return gson.fromJson(jsonStr, RespInit::class.java).data
}
}
c) 调用业务接口的方法如下,到这里笔者都是以鲜闻公开的 init 接口为例:
val userID = Request(mContext).init()
d) 同理,接入新闻数据 API 接口获取数据,替换掉前面 mockData() 的方法即可。当然,因为 API 接口有一定的逻辑顺序,比如要获取新闻数据,一定得先初始化是不是,所以代码逻辑还是有微调的,具体可以参见 Demo 代码,效果如下图示:
003.png1.3 实现图片加载
这里笔者使用第三方图片加载库 glide,用法其实跟 Java 基本一致,100% 兼容的好处是吧。
a) 添加依赖:
compile "com.github.bumptech.glide:glide:$glide_version"
b) 修改列表 Item 布局,添加 ImageView,并在 Adapter 里添加相应处理即可:
class ViewHolder(view: View, val itemClickListener: (News) -> Unit): RecyclerView.ViewHolder(view) {
fun bind(news: News) {
with(news) {
...
// 图片加载
Glide.with(itemView.context).load(news.image).into(itemView.image)
itemView.setOnClickListener { itemClickListener(this) }
}
}
}
c) 效果如下图所示:
004.pngd) 好吧,UI 有点难看,还是做些调整吧:
005.pngPart-2 多个频道和列表
完成 Part-1,新闻列表雏形已经搭好,但是有个明显的缺陷是扩展性不好。为什么?因为拆解的不够细,Activity 作为一屏,可以承载多个频道和列表 UI,但我们直接在 Activity 上画了一个列表 UI,导致无法复用,无法扩展。是的,这时候就需要重构了,当结构不足以支撑业务扩展的时候。
查看相关源码 点这里 Demo v2.0.0
006.png2.1 第一次重构,支持多个频道和列表
重构部分如上图红线框内所示,添加 ViewPager 承载多个列表;并将列表 UI 拆解成 FragmentNewsList,实现复用:
private fun getChannel(userID: UserID) = doAsync {
val channelList = Request(mContext).getChannelList(userID.user_id)
uiThread {
for (channel in channelList) {
val args: Bundle = Bundle()
args.putString("uid", userID.user_id)
args.putString("cid", channel.id)
mFragments.add(FragmentNewsList.getInstance(args))
mTitles.add(channel.name)
}
content.adapter = NewsPagerAdapter(supportFragmentManager)
tabs.setViewPager(content)
}
}
2.2 添加下拉刷新、上拉加载
作为一个阅读列表,下拉刷新和上拉加载还是必须要有的,在 FragmentNewsList 里添加以下处理即可。亲爱的朋友,能看到这里想必也是对 Kotlin 或者 App 开发极感兴趣的吧,来握个手,辛苦啦。言归正传,注意下面这里还是用了 findViewById,在 Fragment 里,相比在 Activity 里还是略繁琐是吧,还可以精简不?
private fun initView() {
val swipeRefreshLayout = rootView?.findViewById(R.id.swipeRefreshLayout) as SwipeRefreshLayout
// 下拉刷新监听
swipeRefreshLayout.setOnRefreshListener(this)
...
newsList.setOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
...
// 上拉加载判断
if (visibleItemCount + pastVisiblesItems >= totalItemCount - 3) {
loadMore(uid, cid)
}
}
})
}
override fun onRefresh() {
refresh(uid, cid)
}
效果如下图所示:
007.pngPart-3 第二次重构,支持多个功能页
经过 Part-2 的重构,新闻列表已经可以复用,但是如果在 Activity 一屏里还需要添加其他功能页呢,比如实现类似 TabPages 的页面结构?是不是还是扩展性不够好。这里我们继续重构之路,其实与上一 Part 是类似的。
查看相关源码 点这里 Demo v3.0.0
008.png3.1 第二次重构,支持多个功能页
重构部分如上图红线框内所示,添加 TabLayout,这里笔者直接基于一个 tablayout 的库来实现;并将之前的 content 拆解成 FragmentNewsContent,添加简介页 FragmentAbout:
override fun onCreate(savedInstanceState: Bundle?) {
...
mFragments.add(FragmentNewsContent.getInstance(args))
mFragments.add(FragmentAbout.getInstance())
for (i in mTitles.indices) {
mTabEntities.add(TabEntity(mTitles[i], mIconSelectIds[i], mIconUnselectIds[i]))
}
tabs.setTabData(mTabEntities, this, R.id.content, mFragments)
// 以后有时间不妨把这里改成 DSL 来实现吧
}
3.2 实现闪屏页,使用 SharedPreferences 存储 uid
class SplashActivity : FragmentActivity() {
// 扩展了一个 SharedPreferences 的代理,具体可以参见 Demo 里的 Preference.kt
var uid: String by DelegatesExt.preference(this, "UID", "0")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
initUser()
Handler(Looper.getMainLooper()).postDelayed({
// 这里还是用到了 anko 的库,简化了许多,后面单独码一篇文章来介绍吧
startActivity<MainActivity>()
this.finish()
}, 3000);
}
private fun initUser() = doAsync {
val userID = Request(this@SplashActivity).init()
uiThread {
// preference 的赋值
uid = userID.user_id
}
}
}
3.3 实现详情页
新建 DetailActivity,这里简单用 webview 来实现,好似暂时也没什么可以说明的:
override fun onCreate(savedInstanceState: Bundle?) {
...
val url = intent.getStringExtra(URL)
webView.loadUrl(url)
}
同时简单调整了下配色,效果如下图所示:
009.pngPart-4 多个模块
经过 Part-3 的重构和完善,我们已经完成了一个简单的、仅支持文字新闻的 APP。但是不是还缺少点什么?是的,对于一款新闻 APP,视频新闻不可或缺,更何况如今短视频如火如荼是吧。
好吧,现在需求明确了,是添加一个视频功能。相比于前面的文字新闻,视频相对独立,而且必要时也可能需要单独打包成一个视频的 APP 是吧。基于这样的思考,架构又需要调整了,是的,这里笔者说的是架构了,因为前面的重构只是对页面结构的调整,现在开始是对功能模块、APP 架构的调整了。
查看相关源码 点这里 Demo v4.0.0
010.png4.1 第三次重构,支持多个模块
重构部分如上图红线框内所示,拆解文字新闻作为一个单独的业务模块 news;同时拆解 Model 模块,方便其他新业务模块(比如 video)调用。新增 router 作为模块间跳转路由,这里笔者直接简单封装了隐式跳转,没有采用其他开源的路由框架。
好吧,到这里其实应用的架构雏形已经搭好了,前面我们一直是以工程结构的脑图在展示,现在我们换一种表现方式吧:
011.png而在真正的项目里,我们扩展成这个样子了:
012.png4.2 谈谈架构
文章最后,谈谈笔者对架构的理解吧。其实大部分理解都穿插在上面复盘的过程中了,这里算是总结吧。
架构是随着业务拓展而迭代升级的,像前文的演进之路,可能并不是每个工程师都能有预见性地铺好路、设计一个好的结构和架构,如果你可以,恭喜你解锁架构师勋章啦。当然现在技术方向这么多,架构师都开始细分领域了吧。嗯,还有更高级的是全栈哦。有点扯远了,回到这篇文章,文中的每一次重构都是必须的吗?不是的,选用与你现在的业务和可预见的未来的业务发展最匹配的页面结构和架构即可。
013.jpgqrcode_card.png愿大家都能飞得更高、飞得更开心...嗯,开心很重要...
网友评论
组件化和插件化的开发里程总结
https://www.jianshu.com/p/df2a6717009d
java.lang.RuntimeException: Unable to get provider vip.frendy.advertisement.provider.GDTFileProvider: java.lang.ClassNotFoundException: Didn't find class "vip.frendy.advertisement.provider.GDTFileProvider" on path: DexPathList[[zip file "/data/app/vip.frendy.news-1/base.apk"],nativeLibraryDirectories=[/data/app/vip.frendy.news-1/lib/arm64, /vendor/lib64, /system/lib64]]
at android.app.ActivityThread.installProvider(ActivityThread.java:5401)
at android.app.ActivityThread.installContentProviders(ActivityThread.java:4971)
at android.app.ActivityThread.handleBindApplication(ActivityThread.java:4911)
at android.app.ActivityThread.access$1600(ActivityThread.java:180)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1536)
at android.os.Handler.dispatchMessage(Handler.java:111)
at android.os.Looper.loop(Looper.java:207)
at android.app.ActivityThread.main(ActivityThread.java:5710)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:900)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:761)