This article has been exclusively published by guolin_blog (Guo Lin), an official wechat account. Take you hand in hand to build a good Android project architecture
directory
- preface
- Architecture Overview
- Gradle configuration is managed uniformly
- The base class encapsulates
- View the binding
- Bottom navigation bar implementation
- Event Bus framework encapsulation
- List schema encapsulation
- Network Architecture Construction
- persistence
- Expectations and Conclusions
preface
Recently, the company is preparing to launch a new project, and the author is in charge of building the project structure, which also happened to consolidate the previous knowledge of Kotlin and other related knowledge, so I extracted the results of building as an open source project to share with everyone. In addition, this project is a good example to learn from Kotlin, and can be used as a blueprint for a new project with minor modifications.
Architecture Overview
What a good architecture requires, according to design principles, are the following:
- Implement the functionality required by the project, laying the foundation for business requirements
- Scalability and configurability are strong enough
- Ease of use, convenient for new members to learn and get started
- Code is highly reusable, allowing you to reuse most of your existing code when adding new features
Before I started, I looked at the architecture of many projects in the company, but most of them were not satisfactory, such as the following questions were flying everywhere:
- The original API is very difficult to use, such as adding a simple burial point, and most of the time you need to go through and copy the existing code to use it
- There are a lot of Adapter in disorder, and each implementation is uneven. The list Item is not reused, and the Item that should be reused is written several times. Adapter is also written one page, which is very difficult to unified management
- The network architecture is very chaotic. The same project may have multiple network architectures due to historical reasons. And the API is so hard to use that you have to manually create a Retrofit Service to call the API each time you use it
- A lot of FindViewByIds, a lot of things to change when you want to change or refactor code
- Package management is disorganized and so on
Here are some things you can learn and consolidate by studying the architecture:
- Kotlin various syntax etc
- Jetpack: ViewModel, LifeCycle, LiveData, Room, ViewBinding
- Kotlin coroutines
- Think about where thread synchronization problems may occur and what to do about them
- Retrofit+OkHttp
- MultiType
- MMKV
- , etc.
Let’s take a look at the overall package division of the project:
Base: encapsulates beans that store all the basic classes of all services, including BaseActivity, BaseFragment, BaseViewModel, list, etc. : Stores all the bean classes, usually Kotlin’s data class Constant: Eventbus: Encapsulates XEventBus, based on LiveData Item: stores all reusable lists item Module: Store all modules divided by business functions (generally divided by pages). The package of each module contains classes required by the module, generally activities/fragments and corresponding ViewModel network: Util: utility class, including Kotlin extended properties, extension functions, widgets: XArchApplication, all custom controls The project Application
Since this is a sample project, I won’t worry about multi-module partitioning for the time being.
Gradle configuration is managed uniformly
To build a project, start with Gradle and put all the necessary dependencies into it to lay the foundation for the following work.
Config. Gradle config. Gradle config.
/** * dependency library versioning */
def versions = [:]
versions.androidx_appcompat = "1.3.1". ext.versions = versions/** * APP version number, plug-in version, compile related version management */
def build_versions = [:]
build_versions.min_sdk = 21
build_versions.app_version_name = "1.0.0". ext.build_versions = build_versions/** * Path constant */
def paths = [:]
paths.room_schema = "$projectDir/schemas"
ext.paths = paths
/** ** storage address management */
def addRepos(RepositoryHandler handler) {
handler.maven {
allowInsecureProtocol true
url 'http://maven.aliyun.com/nexus/content/groups/public/'}... } ext.addRepos =this.&addRepos
/** * Reads the native configuration, mainly for locally differentiated builds (local.properties will not be committed to the repository) */
def readLocalProperty(String key) {
boolean value = false
def file = rootProject.file('local.properties')
if (file.exists() && file.isFile()) {
Properties properties = new Properties()
properties.load(file.newDataInputStream())
value = Boolean.parseBoolean(properties.getProperty(key, 'false'))
}
println(String.format("property key=%s value=%S", key, value))
return value
}
ext.readLocalProperty = this.&readLocalProperty
Copy the code
Among them,
- Versions are the versions of all third-party dependent libraries
- Build_versions is all build related versions, such as minimum SDK, APP version number, etc
- Paths are all path constants
- AddRepos is all warehouse addresses
- ReadLocalProperty reads the local configuration to do some differential configuration.
Properties file is not committed to Git, so properties configured in local.properties are only used to change your local build. For example, if I want to use a thread check tool for local debugging, but I don’t want to affect the APK package compiled by continuous integration, I can add the following line to the local.properties:
THREAD_POOL_SHRINK=false
Copy the code
You can then add the following configuration to build.gradle so that only you can start the plugin on your own computer without affecting the APK package compiled by continuous integration. This is the author of a more commonly used small skills.
// Thread pool Gradle plugin is optimized for thread pool
if (readLocalProperty("THREAD_POOL_SHRINK")) {
apply from: "thread.gradle"
}
Copy the code
Config. gradle: config.gradle: config.gradle: config.gradle: config.gradle: config.
buildscript {
apply from: 'config.gradle'
addRepos(repositories)
dependencies {
classpath "com.android.tools.build:gradle:$build_versions.android_gradle_plugin"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${build_versions.kotlin}"
}
}
allprojects {
addRepos(repositories)
}
...
Copy the code
At this point, Gradle configuration unified management is implemented, which lays a solid foundation for the following multiple modules.
Of course, Gradle configuration unified management is a lot of content can be developed, for multiple modules and even multiple projects there are many solutions on the web, here for the current project needs to adopt the simplest way, this method is suitable for the needs of most small and medium-sized projects.
The base class encapsulates
Let’s start with the simplest base class encapsulation and go directly to the code:
abstract class BaseActivity : SwipeBackActivity(), IGetPageName {
override fun onCreate(savedInstanceState: Bundle?). {
super.onCreate(savedInstanceState)
setSwipeBackEnable(swipeBackEnable())
}
override fun onStart(a) {
super.onStart()
// You can add page dot here
}
override fun onStop(a) {
super.onStop()
// You can add page dot here
}
/** * This function is enabled by default. If you want to disable this function, please override this method */
protected open fun swipeBackEnable(a) = true. }Copy the code
- Overall, the base class design must be an Abstract class and provide the necessary hook functions for subclass customization and common common functions
- There are some common operations that can be done in the base class lifecycle functions, which are generally page points, where pageName can be provided by the base base class implementation IGetPageName
- For an Activity, some pages may require a right swipe back function, so let’s just make BaseActivity inherit SwipeBackActivity
Other Basefragments and BaseViewModels are relatively simple and will not be described here.
In particular, I prefer not to add extra methods to the base class, trying to keep a class pure. In the case of BaseActivity, I don’t want to add fancy methods like doCreate and getContentView, because that would confuse first-time colleagues and require them to look at the base class implementation from time to time.
In terms of specific use, the author suggests that all modules be divided into a package, such as MainActivity and MainViewModel in main Package:
For details, please refer to the official architecture diagram provided by Google:
This project omitted the Repository layer, considering the small and medium-sized sample project and the cost of learning, we have not made a layer at the moment, if necessary, you can implement it by yourself.
View the binding
When it comes to view binding, the following things usually come to mind:
- FindViewById: Repetitive, no way to avoid null Pointers and strong cast type errors (currently avoided by generics)
- DataBinding: This is a tool for MVVM bidirectional binding. Strictly speaking, it is not part of the view binding tool. View binding is only part of DataBinding
- ButterKnife/Kotlin – Android – Extention: view binding tool, because from the beginning of the AGP – version 5.0, now the value of R class generation is no longer a constant, the two tools have been abandoned (reference: blog.csdn.net/c10WTiybQ1Y…
- ViewBinding: The ViewBinding tool eliminates the need to write findViewById by hand and avoids null Pointers and strong type errors that findViewById can cause (*)
Based on these considerations, the project decided to use ViewBinding.
The following is the encapsulation of ViewBinding in BaseActivity and BaseFragment:
/** * Activity base class */
abstract class BaseActivity<T : ViewBinding>(val inflater: (inflater: LayoutInflater) -> T) : SwipeBackActivity() {
protected lateinit var viewBinding: T
override fun onCreate(savedInstanceState: Bundle?). {
super.onCreate(savedInstanceState)
viewBinding = inflater(layoutInflater)
setContentView(viewBinding.root)
}
}
/** * Fragment base class */
abstract class BaseFragment<T : ViewBinding>(valinflater: (inflater: LayoutInflater, container: ViewGroup? , attachToRoot:Boolean) -> T) : Fragment() {
protected lateinit var viewBinding: T
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup? , savedInstanceState:Bundle?).: View? {
viewBinding = this.inflater(inflater, container, false)
return viewBinding.root
}
}
Copy the code
We define a generic parameter and a member that subclasses can access. Then we add a higher-order ViewBinding initialization function to the main constructor and call the higher-order ViewBinding initialization function from the corresponding onCreate/onCreateView. To inherit the Base class, you simply pass XXXViewBinding::inflate, as in the case of MainActivity:
/**
* 首页
*/
class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::inflate) {
override fun onCreate(savedInstanceState: Bundle?). {
super.onCreate(savedInstanceState)
// You can use viewBinding directly
viewBinding.xxx
}
}
Copy the code
Bottom navigation bar implementation
The bottom navigation bar is basically a standard configuration of a project. There are many current implementation schemes, and the author chose the FragmentTabHost scheme which is relatively mature and extensible.
For related articles, see Android Bottom Navigation Bar (Bottom Tab) Best Practices
The implementation of the bottom navigation bar is implemented using FragmentTabHost+Fragment, but the FragmentTabHost is modified to prevent the Fragment from being destroyed during the switch.
For example code, see mainactivity.kt:
/** * initializes the bottom column */
private fun initTabs(a) {
val tabs = listOf(
Tab(TabId.HOME, getString(R.string.page_home), R.drawable.selector_btn_home, HomeFragment::class),
Tab(TabId.SMALL_VIDEO, getString(R.string.page_small_video), R.drawable.selector_btn_small_video, SmallVideoFragment::class),
Tab(TabId.ACGN, getString(R.string.page_acgn), R.drawable.selector_btn_acgn, AcgnFragment::class),
Tab(TabId.GOLD, getString(R.string.page_gold), R.drawable.selector_btn_gold, GoldFragment::class),
Tab(TabId.MINE, getString(R.string.page_mine), R.drawable.selector_btn_mine, MineFragment::class)
)
viewBinding.fragmentTabHost.run {
// Call the setup() method to set the FragmentManager and specify the layout container to load the Fragment
setup(this@MainActivity, supportFragmentManager, viewBinding.fragmentContainer.id)
tabs.forEach {
// Here is the syntax for deconstruction
val (id, title, icon, fragmentClz) = it
val tabSpec = newTabSpec(id).apply {
setIndicator(TabIndicatorView(this@MainActivity).apply {
viewBinding.tabIcon.setImageResource(icon)
viewBinding.tabTitle.text = title
})
}
addTab(tabSpec, fragmentClz.java, null)
}
setOnTabChangedListener { tabId ->
currentTabId = tabId
updateTitle()
}
}
}
/** * sets the currently selected TAB */
private fun setCurrentTab(@TabId tabID: String) {
viewBinding.fragmentTabHost.setCurrentTabByTag(tabID)
}
Copy the code
In the initTabs function, we setup the FragmentManager by calling the setup method of FragmentTabHost and specifying the layout container to load the Fragment. Then use the addTab method to pass in the created TabSpec. TabIndicatorView is our custom control for each bottom navigation bar display.
Event Bus framework encapsulation
When we think of the event bus, we can only think of:
- EventBus library
- RXJava
- LiveData
Now that we’re on the Jetpack bandwagon, we use LiveData to implement a simple, usable event bus framework. The convention starts with the results:
Send an event anywhere via XEventBus’s POST method:
XEventBus.post(EventName.REFRESH_HOME_LIST, "Get cash page notice home page refresh data")
Copy the code
Subscriber receives:
XEventBus.observe(viewLifecycleOwner, EventName.REFRESH_HOME_LIST) { message: String ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
Copy the code
The general class preview is as follows:
There are a few classes, so let’s start to explain the implementation principle.
If you are familiar with LiveData, you will receive the last message when you add a new Observer, which is essentially a sticky subscription. If you do not need a sticky subscription, you will need to modify the Observer:
class EventObserverWrapper<T>(
liveData: EventLiveData<T>,
sticky: Boolean.private val observerDelegate: Observer<in T>
) : Observer<T> {
private var preventNextEvent = false
companion object {
private const val START_VERSION = -1
}
init {
if(! sticky) {val version = ReflectHelper.of(liveData).getField("mVersion") as? Int? : START_VERSION preventNextEvent = version > START_VERSION } }override fun onChanged(t: T) {
if (preventNextEvent) {
preventNextEvent = false
return
}
observerDelegate.onChanged(t)
}
}
Copy the code
We pass LiveData and sticky subscription parameters during construction through the proxy Observer, and determine in init that if the caller does not need sticky subscription, then skip the next onChanged trigger based on the LiveData version number mVersion.
The mVersion of LiveData needs to be obtained through reflection.
Wrap EventLiveData with EventObserverWrapper and add a sticky parameter to the subscribed Observer:
class EventLiveData<T> : MutableLiveData<T>() {
fun observe(owner: LifecycleOwner, sticky: Boolean, observer: Observer<in T>) {
observe(owner, wrapObserver(sticky, observer))
}
private fun wrapObserver(sticky: Boolean, observer: Observer<in T>): Observer<T> {
return EventObserverWrapper(this, sticky, observer)
}
}
Copy the code
Finally, provide a facade class externally:
object XEventBus {
private val channels = HashMap<String, EventLiveData<*>>()
private fun <T> with(@EventName eventName: String): EventLiveData<T> {
synchronized(channels) {
if(! channels.containsKey(eventName)) { channels[eventName] = EventLiveData<T>() }return (channels[eventName] as EventLiveData<T>)
}
}
fun <T> post(@EventName eventName: String, message: T) {
val eventLiveData = with<T>(eventName)
eventLiveData.postValue(message!!)
}
fun <T> observe(owner: LifecycleOwner.@EventName eventName: String, sticky: Boolean = false, observer: Observer<T>) {
with<T>(eventName).observe(owner, sticky, observer)
}
}
Copy the code
In this XEventBus object, Channels stores all EventLiveData, and you can get an EventLiveData based on eventName by using the with function.
We also provide post and observe functions externally:
- Post is used to send the event, passing in the event name and the specific message, and finally calling the postValue method of LiveData
- Observe is used to subscribe to events, passing in the event name and Observer, and finally calling the LiveData observe method
By encapsulating the event bus with LiveData, we eliminate the need to manually unsubscribe, but there is one more troublesome event that needs to be resolved: subscribe with Observe instead of observeForever, and receive messages only when LifecycleOwner is active. For example, if you send an event to an Activity that has been paused, you will receive the message only when the Activity is returned.
It is very common to send an event to an Activity/Fragment that has already been paused. In fact, we can subscribe to observeForever, but this subscription requires manual unsubscription, which brings inconvenience to the API. To take advantage of observe’s automatic unsubscribe convenience and receive events in pause mode, I decided to port LiveData’s source code myself to achieve this effect.
Let’s copy the classes in the LiveData package to our project, and let the duties of the notice method trigger the event callback be removed to judge if the Observer is active:
private void considerNotify(ObserverWrapper observer) {
/* if (! observer.mActive) { return; } // Check latest state b4 dispatch. Maybe it changed state but we didn't get the event yet. // // we still first check observer.active to keep it as the entrance for events. So even if // the observer moved to an active state, if we've not received that event, we better not // notify for a more predictable notification order. if (! observer.shouldBeActive()) { observer.activeStateChanged(false); return; } * /
if (observer.mLastVersion >= mVersion) {
return;
}
observer.mLastVersion = mVersion;
observer.mObserver.onChanged((T) mData);
}
Copy the code
For scenarios without LifecycleOwner, you need to implement the LifecycleOwner yourself. In most cases, you can directly obtain the LifecycleOwner through the Activity/Fragment.
At this point, the encapsulation of the event bus architecture is complete.
List schema encapsulation
This is the top priority of the whole project, and is also the most commonly used and complex function in the project. If the package is not good, it will affect the development efficiency and project quality. The pain points of this part are as follows:
- High reuse of Adapter and Item
- Talk to the ViewModel and load data through the ViewModel
- Configurability is strong enough, but there is no need to re-configure redundant properties such as Adapter, LayoutManager, and so on
- Enable and disable pull-down refresh and pull-up loading
- Data preloading is supported
- Blank page, abnormal page
- Supports local data loading and network data loading. If loading network data fails, the system automatically retries based on the network status
- Support common listening, such as short press, long press, Item sub-view click listening
- , etc.
Based on the above thinking, the author encapsulated an XRecyclerView control for the project, using a very simple method:
- Place an XRecyclerView inside the XML
- Simple configuration in the code
- Inherit BaseRecyclerViewModel and implement the core loadData method inside
XRecyclerView configuration example code in homefragment.kt, as follows:
viewBinding.rvList.init(
XRecyclerView.Config()
.setViewModel(viewModel)
.setOnItemClickListener(object : XRecyclerView.OnItemClickListener {
override fun onItemClick(parent: RecyclerView, view: View, viewData: BaseViewData<*>, position: Int, id: Long) {
Toast.makeText(context, "Entry click:${viewData.value}", Toast.LENGTH_SHORT).show()
}
})
.setOnItemChildViewClickListener(object : XRecyclerView.OnItemChildViewClickListener {
override fun onItemChildViewClick(parent: RecyclerView, view: View, viewData: BaseViewData<*>, position: Int, id: Long, extra: Any?). {
if (extra is String) {
Toast.makeText(context, "Item subview click:$extra", Toast.LENGTH_SHORT).show()
}
}
})
)
Copy the code
By calling the init method of XRecyclerView, pass in an XrecyclerView. Config object that contains all configuration information. For most of the configurations, you can use the recommended default values without configuring them.
In the example code, we set up the click listener for the Item and the click listener for the face View on the Item. In addition, we pass in a BaseRecyclerViewModel object, which is responsible for providing the data source for XRecyclerView. Homeviewmodel.kt, as follows:
class HomeViewModel : BaseRecyclerViewModel() {
override fun loadData(isLoadMore: Boolean, isReLoad: Boolean, page: Int) {
viewModelScope.launch {
// Simulate network data loading
delay(1000L)
val time = DateFormat.format("MM-dd HH:mm:ss", System.currentTimeMillis())
val viewDataList: List<BaseViewData<*>>
if(! isLoadMore) { viewDataList = listOf<BaseViewData<*>>( Test1ViewData("a-$time"),...). }else {
// Simulate a network exception on page 5
if (page == 5) {
postError(isLoadMore)
return@launch
}
viewDataList = listOf<BaseViewData<*>>(
Test1ViewData("a-$time"),...). } postData(isLoadMore, viewDataList) } }@PageName
override fun getPageName(a) = PageName.HOME
}
Copy the code
In the sample code, we mainly do the following things:
- Inherit from BaseRecyclerViewModel to implement the most important loadData function
- In loadData, we can get whether the current load is the first page load or more data load, whether it is a retry load, page number and cursor offset, etc
- Based on the above parameters, the coroutine is turned on, the data is requested, the data is wrapped as a BaseViewData<*> list, and the data is submitted by calling postData provided by the parent class
- You can also submit a load error by calling the postError provided by the parent class
Let’s start with a brief explanation of how the list architecture works. The overall architecture is shown in the figure below:
To consider the reuse of Item and Adapter, we introduce MultiType in source code, encapsulating a BaseAdapter, and providing some of the most generic functions in it:
open class BaseAdapter : MultiTypeAdapter() {
init {
register(LoadMoreViewDelegate())
...
}
open fun setViewData(viewData: List<BaseViewData< * > >) {
items.clear()
items.addAll(viewData)
notifyDataSetChanged()
}
...
}
Copy the code
Also, according to the use of MultiType, we encapsulate a BaseItemViewDelegate:
abstract class BaseItemViewDelegate<T : BaseViewData<*>, VH : RecyclerView.ViewHolder> : ItemViewDelegate<T, VH>() {
@CallSuper
override fun onBindViewHolder(holder: VH, item: T) {
holder.itemView.setOnClickListener {
performItemClick(it, item, holder)
}
holder.itemView.setOnLongClickListener {
return@setOnLongClickListener performItemLongClick(it, item, holder)
}
}
/** * click listen */
protected fun performItemClick(view: View, item: BaseViewData<*>, holder: RecyclerView.ViewHolder) {
val recyclerView = getRecyclerView(view)
if (null! = recyclerView) {val position: Int = holder.absoluteAdapterPosition
val id = holder.itemId
recyclerView.performItemClick(view, item, position, id)
}
}
/** * long press to listen */
protected fun performItemLongClick(view: View, item: BaseViewData<*>, holder: RecyclerView.ViewHolder): Boolean {
var consumed = false
val recyclerView = getRecyclerView(view)
if (null! = recyclerView) {val position: Int = holder.absoluteAdapterPosition
val id = holder.itemId
consumed = recyclerView.performItemLongClick(view, item, position, id)
}
return consumed
}
/** * subview click listen */
protected fun performItemChildViewClick(view: View, item: BaseViewData<*>, holder: RecyclerView.ViewHolder, extra: Any?). {
val recyclerView = getRecyclerView(view)
if (null! = recyclerView) {val position: Int = holder.absoluteAdapterPosition
val id = holder.itemId
recyclerView.performItemChildViewClick(view, item, position, id, extra)
}
}
/** * get your own XRecyclerView */
private fun getRecyclerView(child: View): XRecyclerView? {
var recyclerView: XRecyclerView? = null
var parent: ViewParent = child.parent
while (parent is ViewGroup) {
if (parent is XRecyclerView) {
recyclerView = parent
break
}
parent = parent.getParent()
}
return recyclerView
}
}
Copy the code
In the BaseItemViewDelegate, we handled all click listening of RecyclerView, including short click, long click, click listening of Item child View, by constantly backtracking the parent View, Finally, the click event will be entrusted to the XRecyclerView we will encapsulate to deal with, and finally to the user (Activity/Fragment, etc.) back and forth.
The core idea of MultiType is that one class corresponds to one Item. To further isolate and allow the same class to correspond to multiple items, we abstract a BaseViewData wrapper class:
open class BaseViewData<T>(var value: T) {
...
}
Copy the code
So by inheriting different BaseViewData can be different items, at the same time, we also need to modify the source of MultiType accordingly.
LoadMoreViewData LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate LoadMoreViewDelegate
class LoadMoreViewData(@LoadMoreState loadMoreState: Int) : BaseViewData<Int>(loadMoreState) {
}
class LoadMoreViewDelegate : BaseItemViewDelegate<LoadMoreViewData, LoadMoreViewDelegate.ViewHolder>() {...class ViewHolder(val viewBinding: ViewRecyclerFooterBinding) : RecyclerView.ViewHolder(viewBinding.root) {
}
}
Copy the code
Now we need to inherit BaseAdapter and encapsulate a LoadMoreAdapter. The core idea is to always treat LoadMoreViewData as the last item in the list. The setLoadMoreState function is provided to set the loading of more states.
A brief example code is as follows:
class LoadMoreAdapter : BaseAdapter() {
private val loadMoreViewData = LoadMoreViewData(LoadMoreState.LOADING)
/** * rewrite setViewData to add more entries */
override fun setViewData(viewData: List<BaseViewData< * > >) {
val mutableViewData = viewData.toMutableList()
mutableViewData.add(loadMoreViewData)
super.setViewData(mutableViewData)
}
fun setLoadMoreState(@LoadMoreState loadMoreState: Int) {
val position = itemCount - 1
if (isLoadMoreViewData(position)) {
loadMoreViewData.value = loadMoreState
notifyItemChanged(position)
}
}
...
}
Copy the code
The core idea is to first package a LoadMoreRecyclerView. The principle is to judge the rolling state and quantity of RecyclerView by addOnScrollListener, and trigger the onLoadMore callback of preloading:
class LoadMoreRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
private var onLoadMoreListener: OnLoadMoreListener? = null
private lateinit var scrollChangeListener: LoadMoreRecyclerScrollListener
override fun setAdapter(adapter: Adapter< * >? {
// The Adapter passed in must be BaseLoadMoreAdapter
val loadMoreAdapter = adapter as LoadMoreAdapter
// LayoutManager must be set before Adapter is set
scrollChangeListener = object : LoadMoreRecyclerScrollListener(layoutManager!!) {
override fun onLoadMore(page: Int, totalItemsCount: Int) {
// Triggers preloading
if(canLoadMore) { onLoadMoreListener? .onLoadMore(page, totalItemsCount) } } } addOnScrollListener(scrollChangeListener)super.setAdapter(adapter)
}
fun setOnLoadMoreListener(listener: OnLoadMoreListener) {
this.onLoadMoreListener = listener
}
interface OnLoadMoreListener {
fun onLoadMore(page: Int, totalItemsCount: Int)}... }Copy the code
With more loading done, we started thinking about how to implement the pull-down refresh. PtrFrameLayout encapsulates a PullRefreshLayout:
class PullRefreshLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : PtrFrameLayout(context, attrs, defStyleAttr), PtrUIHandler {
...
}
Copy the code
We also have a problem with the source of the data, we need a general base class BaseRecyclerViewModel:
abstract class BaseRecyclerViewModel : BaseViewModel() {
/** * home/drop down refreshed data */
val firstViewDataLiveData = MutableLiveData<List<BaseViewData<*>>>()
/** * more data */
val moreViewDataLiveData = MutableLiveData<List<BaseViewData<*>>>()
/**
* 页码
*/
private var currentPage = AtomicInteger(0)
/** * Subclasses override this function to load data * submit data via postData after data is loaded * submit exception via postError after data is loaded **@paramIsLoadMore Indicates whether to load more * at this time@paramIsReLoad Whether this is a reload (page and other parameters should not change) *@paramPage page * /
abstract fun loadData(isLoadMore: Boolean, isReLoad: Boolean, page: Int)
fun loadDataInternal(isLoadMore: Boolean, isReLoad: Boolean) {
if(needNetwork() && ! isNetworkConnect()) { postError(isLoadMore)return
}
if(! isLoadMore) { currentPage.set(0)}else if(! isReLoad) { currentPage.incrementAndGet() } loadData(isLoadMore, isReLoad, currentPage.get()}/** * Submit data */
protected fun postData(isLoadMore: Boolean, viewData: List<BaseViewData< * > >) {
if (isLoadMore) {
moreViewDataLiveData.postValue(viewData)
} else {
firstViewDataLiveData.postValue(viewData)
}
}
/** * Commit load exception */
protected fun postError(isLoadMore: Boolean) {
if (isLoadMore) {
moreViewDataLiveData.postValue(LoadError)
} else {
firstViewDataLiveData.postValue(LoadError)
}
}
...
}
Copy the code
The core function of BaseRecyclerViewModel is to provide the loadDataInternal function to be called by the XRecyclerView to be wrapped, triggering the data loading logic. A subclass of BaseRecyclerViewModel can then override the loadData function to implement concrete data loading logic. Since subclasses typically start threads in loadData to loadData, we need to wrap the page numbers and other information in atomic classes.
After data loading is completed, send data to LiveData through postData or postError, do a listener in XRecyclerView can get these data, and finally give Adapter to process and refresh RecyclerView.
Finally, we implement a facade control XRecyclerView to wrap all functions:
class XRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {
fun init(config: Config) {
config.check(context)
this.config = config
initView()
initData()
}
private fun initView(a){}private fun initData(a){}class Config {}... }Copy the code
XRecyclerView is a custom composite control that provides configuration access through Config and encapsulates controls such as blank exception pages and Loading. In addition, we also listen for network status to implement automatic retry, which I won’t elaborate on.
Network Architecture Construction
Network architecture is encapsulated by Retrofit+OkHttp+ coroutine. Take a look at the general preview:
Let’s take a look at the encapsulation results first.
Before the network request, we define the network interface:
interface INetworkService {
@GET("videodetail")
suspend fun requestVideoDetail(@Query("id") id: String): BaseResponse<VideoBean>
}
Copy the code
Then a network interface corresponds to creating a simple object of type BaseNetworkApi, such as NetworkApi:
object NetworkApi : BaseNetworkApi<INetworkService>("http://172.16.47.112:8080/XArchServer/") {
suspend fun requestVideoDetail(id: String) = getResult {
service.requestVideoDetail(id)
}
}
Copy the code
When we inherit and create the BaseNetworkApi object, we need to pass in the number of construction lines that baseUrl gives to the BaseNetworkApi, and the generic parameter passes in the network interface that we just defined. Finally, the suspension function of network API is provided externally, which calls service.xxx() function for specific network request, and service is the concrete implementation of network interface.
We also wrapped getResult for network error handling and request retries, as well as converting BaseResponse to Result with exception information, which is a standard class that Kotlin gave us.
Finally, we open a coroutine in the ViewModel and get the result of the network request simply by calling NetworkApi’s requestXXX method:
class SmallVideoViewModel : BaseViewModel() {
val helloWorldLiveData = MutableLiveData<Result<VideoBean>>()
fun requestVideoDetail(id: String) {
viewModelScope.launch {
val result = NetworkApi.requestVideoDetail(id)
helloWorldLiveData.value = result
}
}
}
Copy the code
So far, is one of the simplest network request example, remember to start the server Tomcat to test success, the corresponding server source code here (with IDEA open) : github.com/huannan/XAr…
The server side is the simplest Java Web project, encapsulating the most basic servlets and introducing FastJson. The code is relatively simple and will not be explained in detail, if you are interested. The project structure is as follows:
BaseNetworkApi: BaseNetworkApi: BaseNetworkApi: BaseNetworkApi
abstract class BaseNetworkApi<I>(private val baseUrl: String) : IService<I> {
protected val service: I by lazy {
getRetrofit().create(getServiceClass())
}
protected open fun getRetrofit(a): Retrofit {
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(getOkHttpClient())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
private fun getServiceClass(a): Class<I> {
val genType = javaClass.genericSuperclass as ParameterizedType
return genType.actualTypeArguments[0] as Class<I>
}
private fun getOkHttpClient(a): OkHttpClient {
val okHttpClient = getCustomOkHttpClient()
if (null! = okHttpClient) {return okHttpClient
}
return defaultOkHttpClient
}
protected open fun getCustomOkHttpClient(a): OkHttpClient? {
return null
}
protected open fun getCustomInterceptor(a): Interceptor? {
return null
}
protected suspend fun <T> getResult(block: suspend() - >BaseResponse<T>): Result<T> {
for (i in 1..RETRY_COUNT) {
try {
val response = block()
if(response.code ! = ErrorCode.OK) {throw NetworkException.of(response.code, "response code not 200")}if (response.value == null) {
throw NetworkException.of(ErrorCode.VALUE_IS_NULL, "response value is null")}return Result.success(response.value)
} catch (throwable: Throwable) {
if (throwable is NetworkException) {
return Result.failure(throwable)
}
if ((throwable is HttpException && throwable.code() == ErrorCode.UNAUTHORIZED)) {
// Refresh the token here and try again}}}return Result.failure(NetworkException.of(ErrorCode.VALUE_IS_NULL, "unknown"))}companion object {
private const val RETRY_COUNT = 2
private val defaultOkHttpClient by lazy {
val builder = OkHttpClient.Builder()
.callTimeout(10L, TimeUnit.SECONDS)
.connectTimeout(10L, TimeUnit.SECONDS)
.readTimeout(10L, TimeUnit.SECONDS)
.writeTimeout(10L, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
builder.addInterceptor(CommonRequestInterceptor())
builder.addInterceptor(CommonResponseInterceptor())
if (BuildConfig.DEBUG) {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
builder.addInterceptor(loggingInterceptor)
}
builder.build()
}
}
}
Copy the code
The BaseNetworkApi uses getServiceClass to get the generic parameters passed in by the subclass by creating an Interface with generic parameters.
Then there’s the implementation of a service, which is lazy-loaded, which is Retrofit+OkHttp, which we’re all familiar with. Which defaultOkHttpClient I put in the companion object, the purpose is to ensure that there is only one defaultOkHttpClient.
Finally, the most complex area of network retries and exception handling, provides a getResult function for subclasses. The core idea is to ensure that the network request is a high-order function, which is called in a loop, the number of loops is the number of network retries. Within the loop, we can handle exceptions and retry (that is, control whether or not to return) based on the information returned by the network.
In addition, in the getResult function, we also changed BaseResponse to Result to bring the exception information back to the caller as well.
persistence
This section is mainly about the use of Room and the simple packaging of MMKV. The sample code is as follows:
@Database(entities = [User::class], version = 1)
abstract class XDatabase : RoomDatabase() {
abstract fun userDao(a): UserDao
companion object {
private val db: XDatabase by lazy {
Room.databaseBuilder(
XArchApplication.instance,
XDatabase::class.java, "database-name"
).build()
}
fun userDao(a): UserDao {
return db.userDao()
}
}
}
@Dao
interface UserDao {
@Query("SELECT * FROM user")
suspend fun getAll(a): List<User>
...
}
Copy the code
/** * This class is MMKV encapsulation class, prevent code intrusion */
object XKeyValue {
fun init(application: Application) {
MMKV.initialize(application)
}
fun putBoolean(@Key key: String, value: Boolean) {
MMKV.defaultMMKV().encode(key, value)
}
fun getBoolean(@Key key: String, defaultValue: Boolean = false): Boolean {
return MMKV.defaultMMKV().decodeBool(key, defaultValue)
}
fun putString(@Key key: String, value: String) {
MMKV.defaultMMKV().encode(key, value)
}
fun getString(@Key key: String, defaultValue: String = ""): String {
return MMKV.defaultMMKV().decodeString(key, defaultValue)!!
}
fun putInt(@Key key: String, value: Int) {
MMKV.defaultMMKV().encode(key, value)
}
fun getInt(@Key key: String, defaultValue: Int = 0): Int {
return MMKV.defaultMMKV().decodeInt(key, defaultValue)
}
...
}
Copy the code
It should be mentioned that Room’s API already supports returning suspend functions.
I won’t bother you with this one because it’s a little bit easier.
Expectations and Conclusions
Article mainly take you realized the Gradle configuration unified view management, base class encapsulation, binding, the realization of the navigation bar at the bottom of the list, event bus frame encapsulation, architecture encapsulation structures, persistence, network architecture, it is the author during the set up the core idea of the whole architecture, inside there are a large number of logic and details, can direct access to the source code:
- Client source: github.com/huannan/XAr…
- Server source: github.com/huannan/XAr…
A complete project still has a lot of work to do, such as:
- Route management is not implemented yet
- The introduction of DiffUtil
- Componentized transformation, extracting various business-independent functions into lib-base modules and solving inter-module communication and routing
- Complete the Repository layer and so on
The author will continue to update these functions in the future. If you think this architecture is good or have any questions, you can add huannan88 on the author’s wechat to discuss with us.