kotlin + MVVM + hint + viewBinding + UnPeekLiveData + retrofit

前言

很久没更新简书了,之前写的

这套东西是基于java的,然后emmm已经很久没优化了,8月份也看到朋友的指正,确实有很多问题,虽然现在这套基于kotlin的框架也有些同样的问题,但优化也是时间问题。

简介

现在是一个新项目,在建立这套结构的时候也参考了一些google官方的demo

如sunflower

由于当前项目也不是很复杂

UI:databinding + rxbinding4
数据传递: UnPeekLiveData
网络请求:okhttp3 + Retrofit + Rxjava/flow

结构说明

我们先来看一下官方demo的代码

// demo来自sunflower

// view(GalleryFragment.kt)
@AndroidEntryPoint // 这里通过依赖注入
class GalleryFragment : Fragment() {

    private val adapter = GalleryAdapter()
    private val args: GalleryFragmentArgs by navArgs()
    private var searchJob: Job? = null
    private val viewModel: GalleryViewModel by viewModels()  // 不用手动初始化viewModels

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val binding = FragmentGalleryBinding.inflate(inflater, container, false)
        context ?: return binding.root

        binding.photoList.adapter = adapter  // 绑定适配器
        search(args.plantName)

        binding.toolbar.setNavigationOnClickListener { view ->
            view.findNavController().navigateUp()
        }

        return binding.root
    }

    private fun search(query: String) {
        // Make sure we cancel the previous job before creating a new one
        searchJob?.cancel()
        searchJob = lifecycleScope.launch {
            viewModel.searchPictures(query).collectLatest {  // 发起网络请求
                adapter.submitData(it)
            }
        }
    }
}

这里实现的是通过网络接口,获取图片列表,并通过Paging展示,这里就完全只关心ui逻辑

// viewModel (GalleryViewModel.kt)
@HiltViewModel  // 依赖注入
class GalleryViewModel @Inject constructor(
    private val repository: UnsplashRepository  // 需要拿到数据管理器
) : ViewModel() {
    private var currentQueryValue: String? = null
    private var currentSearchResult: Flow<PagingData<UnsplashPhoto>>? = null

    fun searchPictures(queryString: String): Flow<PagingData<UnsplashPhoto>> {
        currentQueryValue = queryString
        val newResult: Flow<PagingData<UnsplashPhoto>> =
            repository.getSearchResultStream(queryString).cachedIn(viewModelScope)
        currentSearchResult = newResult
        // 这里通过数据管理器 获取pagingData
        return newResult
    }
}

这里就更简单了,这里只是负责链接view和models,而这里的models又通过Repository(数据管理器)来进行管理

// Repository(UnsplashRepository.kt)
class UnsplashRepository @Inject constructor(private val service: UnsplashService) {

    fun getSearchResultStream(query: String): Flow<PagingData<UnsplashPhoto>> {
        return Pager(
            config = PagingConfig(enablePlaceholders = false, pageSize = NETWORK_PAGE_SIZE),
            pagingSourceFactory = { UnsplashPagingSource(service, query) }
        ).flow
    }

    companion object {
        private const val NETWORK_PAGE_SIZE = 25
    }
}

这里我们只关心输入和输出, 输入的是请求的参数, query 输出的是 Flow, 那这里就可以看成一个数据管理中心,他负责处理需要异步获取的数据,无论是网络请求,还是来自数据库,亦或是本地文件。

我的代码

通过上面的sunflower的代码,我们可以看到这个结构其实很简单,通过依赖注入,将整个程序贯穿成

View(Activity||Fragment) + ViewModel (LiveData || Date || Flow || Flowable(RxJava)) +  Repository (请求数据的方法 无论是通过Service || Room) 

建立起上述的想法后,现阶段给予kotlin的架构就很容易搭建了

鉴于之前用livedata出现过数据倒灌

这里使用了 const val unpeek = "com.kunminx.arch:unpeek-livedata:$version" 来避免之前的问题

废话不多说 还是通过登录业务 来展示一下给予kotlin的结构

// LoginActivity.kt (View) 这里屏蔽一下非关键代码
/**
 * phone number login
 * 手机登录页面
 */
@AndroidEntryPoint
class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    val viewModel: LoginViewModel by viewModels()  // 通过依赖注入生成的viewModel
    // private lateinit var loading: LoadingDialog

    @ObsoleteCoroutinesApi
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityLoginBinding.inflate(layoutInflater)
        //loading = //LoadingDialog.Builder(this).setMessage(getString(R.string.logging)).create()

        binding.loginView.recyclerView.layoutManager =   LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)


        binding.loginView.recyclerView.adapter = EasyBaseAdapter(viewModel.loginDrawableList,
            layoutCallBack = { R.layout.item_image },
            convertCallBack = { holder, data, position ->
                holder.getView<ImageView>(R.id.goods_img)?.setImageResource(data)
                holder.getView<ImageView>(R.id.goods_img)?.setOnClickListener {
                    if(position == 0){
                        if(LoginUtils.checkAliPayInstalled(this)){
                            // 支付宝
                            val intent = Intent(this, PayDemoActivity::class.java)
                            intent.putExtra("OrderInfo", "123")
                            intent.putExtra("OrderType", 1)  // 登录
                            startActivity(intent)
                        }else{
                            ToastUtil.showToast(this, "请先安装支付宝")
                        }
                    }else{
                        // 微信
                    }
                }
            })

        // 登录按钮
        binding.loginBtn.setOnClickListener {
            checkLoginInfo()
        }

//        // 立即注册  进入注册页面
//        binding.loginTv.setOnClickListener {
//            startActivity(Intent(this, RegisterActivity::class.java))
//            finish()
//        }

        // 通过账号密码登陆 进入账号登陆页面
        binding.switchTv.setOnClickListener {
            startActivity(Intent(this, LoginAccountActivity::class.java))
            finish()
        }

        // 获取验证码
        binding.sendCheckNumTv.setOnClickListener {
            checkPhoneNum()
        }

        // 验证码返回 这里我把返回的LiveData存放在viewModel 其实你也可以不存 直接在请求的时候,将LiveData作为返回的参数, 我这样分开纯属习惯
        viewModel.requestCaptchaDate.observe(this, Observer {
            if( it.code == 0) {
                binding.checkNumEdt.setText(it.data)
            }
        })

        setContentView(binding.root)
    }

    // 这里是输入的逻辑交验 属于用户交互的业务逻辑
    @ObsoleteCoroutinesApi
    private fun checkPhoneNum(){
       if (binding.phoneInputEdt.text.toString().trim().isEmpty()) {
            ToastUtil.showToast(this, getString(R.string.sign_please_enter_phone_number))
            return
        }

        if (!RegexUtils.checkPhoneNum(binding.phoneInputEdt.text.toString().trim())) {
            ToastUtil.showToast(this, getString(R.string.sign_please_enter_correct_phone_number))
            return
        }

        viewModel.customerCaptcha(this, binding.phoneInputEdt.text.toString())
        binding.sendCheckNumTv.setTextColor(resources.getColor(R.color.main_menu_select_text_color, null))
        startTimer()
    }

    /**
     * check login
     * 校验登录
     */
    private fun checkLoginInfo() {
        if (binding.phoneInputEdt.text.toString().trim().isEmpty()) {
            ToastUtil.showToast(this, getString(R.string.sign_please_enter_phone_number))
            return
        }

        if (!RegexUtils.checkPhoneNum(binding.phoneInputEdt.text.toString().trim())) {
            ToastUtil.showToast(this, getString(R.string.sign_please_enter_correct_phone_number))
            return
        }

        if (binding.checkNumEdt.text.toString().trim().isEmpty()) {
            ToastUtil.showToast(this, getString(R.string.sign_please_enter_verification_code))
            return
        }

        loading.show()

        viewModel.phoneLoginRequest(
            this,
            binding.phoneInputEdt.text.toString().trim(),
            binding.checkNumEdt.text.toString().trim()
        )
    }

    override fun onResume() {
        super.onResume()
        viewModel.requestDate.observe(this) {
            loading.dismiss()
            binding.phoneInputEdt.setText("")
            binding.checkNumEdt.setText("")
            if (it.code == 0) {
                // 这里是实现落地,存放进数据库
                viewModel.saveUserInfo(it.data.user, it.data.token)
                val userInfo = UserInfo(
                    it.data.user.ID,
                    it.data.user.nickName,
                    it.data.user.userName,
                    it.data.token,
                    System.currentTimeMillis(),
                    // 用户信息
                    it.data.user.CreatedAt,
                    it.data.user.UpdatedAt,
                    it.data.user.activeColor,
                    it.data.user.authorityId,
                    it.data.user.baseColor,
                    it.data.user.headerImg,
                    it.data.user.sideMode,
                    it.data.user.uuid
                )
                // 如果是游客登录 这里更新一下内存缓存
                ApplicationRepository.instance.setUserInfo(userInfo)
                ToastUtil.showToast(this, it.msg)
                // 登录成功
                startActivity(Intent(this, MainActivity::class.java))
                finish()
            }
        }
    }


    @ObsoleteCoroutinesApi
    fun startTimer(){
        // 计时器启动  倒计60秒可再次请求获取验证码
        TickerUtils.TickerBuilder()
            .apply {
                delayMillis = 1000
                finishTime = 60
                scope = viewModel.viewModelScope
                func = {
                    binding.sendCheckNumTv.text = "获取验证码"
                    binding.sendCheckNumTv.setTextColor(resources.getColor(R.color.register_login_text_color, null))
                }
                progress = {
                    binding.sendCheckNumTv.text =  (60 - it).toString() + "秒后再次请求"
                }
            }.build()
            .startTicker()
    }


}

上面可以看的出来,所有的用户ui的操作逻辑,我都放到了Activity,其实这并没有太减负,但是好在其他结构 ViewModel 或是 Repository 就不需要关心这些操蛋的逻辑了

// LoginViewModel.kt (ViewModel)

@HiltViewModel
class LoginViewModel @Inject constructor( val loginRepository: LoginRepository) : ViewModel() {
        // 登录返回
    var requestDate: UnPeekLiveData<CustomerLoginRsp> = UnPeekLiveData.Builder<CustomerLoginRsp>()
        .setAllowNullValue(false)
        .create()
        // 验证码返回
    var requestCaptchaDate: UnPeekLiveData<CustomerCaptchaRsp> = UnPeekLiveData.Builder<CustomerCaptchaRsp>()
        .setAllowNullValue(false)
        .create()
        // 图片
    val loginDrawableList = arrayListOf<Int>(
        R.mipmap.alipay_icon,
        R.mipmap.wechat_icon
    )

    /**
     * 手机登录请求
     */
    fun phoneLoginRequest(lifecycleOwner: LifecycleOwner, phoneNum: String, code: String){
        loginRepository.customerLogin(lifecycleOwner, CustomerLoginReq(phoneNum, code), requestDate)
    }

    /**
     * 账号密码登录请求
     */
    fun accountLoginRequest(lifecycleOwner: LifecycleOwner, account: String, password: String){

    }

    /**
     * 获取验证码
     */
    fun customerCaptcha(lifecycleOwner: LifecycleOwner, phoneNum: String){
        loginRepository.customerCaptcha(lifecycleOwner , CustomerCaptchaReq(phoneNum), requestCaptchaDate)
    }

    // 数据库储存数据
    fun saveUserInfo(info: CustomerLoginRsp.User, token: String){
        GlobalScope.launch {
            val userInfo = UserInfo(
                info.ID,
                info.nickName,
                info.userName,
                token,
                System.currentTimeMillis(),
                // 用户信息
                info.CreatedAt,
                info.UpdatedAt,
                info.activeColor,
                info.authorityId,
                info.baseColor,
                info.headerImg,
                info.sideMode,
                info.uuid
            )
            loginRepository.deleteUserInfo(userInfo)
            loginRepository.insertUserInfo(userInfo)
        }
    }
}

从viewModel可以看的出来,这里只关心需要与数据交互的部分,比如说网络请求,数据本地持久化

// LoginRepository.kt (Repository)
/**
 * login repository
 * 登录相关
 */
@Singleton
class LoginRepository @Inject constructor(
    private val service: CustomerUserService,  // 来自网络的数据
    private val userDao: UserInfoDao                     // 来自本地的数据
) {

    /**
     * 用户注册
     */
    fun customerRegister(
        lifecycleOwner: LifecycleOwner,
        req: CustomerRegisterReq,
        requestDate: UnPeekLiveData<CustomerRegisterRsp>
    ){
        service.customerRegister(req)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
            .subscribe(
                {
                    // login success
                    requestDate.value = it
                }, {
                    // request error
                    ToastUtil.showToast(GlobalApplication.instance.baseContext, it.message.toString())
                    it.printStackTrace()
                }
            )
    }

    /**
     * 用户登录
     */
    fun customerLogin(
        lifecycleOwner: LifecycleOwner,
        req: CustomerLoginReq,
        requestDate: UnPeekLiveData<CustomerLoginRsp>
    ) {
        service.customerLogin(req)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
            .subscribe(
                {
                    // login success
                    requestDate.value = it
                }, {
                    // request error
                    ToastUtil.showToast(GlobalApplication.instance.baseContext, it.message.toString())
                    it.printStackTrace()
                }
            )
    }

    /**
     * 获取验证码
     */
    fun customerCaptcha(
        lifecycleOwner: LifecycleOwner,
        req: CustomerCaptchaReq,
        requestDate: UnPeekLiveData<CustomerCaptchaRsp>
    ) {
        service.customerCaptcha(req)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
            .subscribe(
                {
                    requestDate.value = it
                }, {
                    ToastUtil.showToast(GlobalApplication.instance.baseContext, it.message.toString())
                    it.printStackTrace()
                }
            )
    }

    /**
     *  校验token
     */
    fun checkToken(
        lifecycleOwner: LifecycleOwner,
        requestDate: UnPeekLiveData<AnyResponseBean>
    ) {
        service.checkToken()
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
            .subscribe(
                {
                    requestDate.value = it
                }, {
                    it.printStackTrace()
                }
            )
    }

    fun hasUser(info: UserInfo): Boolean{
        return userDao.hasUser(info.id).value == 1
    }

    fun insertUserInfo(info: UserInfo) {
        userDao.insertUserInfo(info)
    }

    fun updateUserInfo(info: UserInfo){
        userDao.updateUserInfo(info)
    }

    fun deleteUserInfo(info: UserInfo) {
        userDao.deleteUserInfo(info.id)
    }

}

Repository这部分则只处理数据相关的业务,而这个部分,是可以很方便的组合复用,一个ViewModel 可以绑定任意多个Repository,以达到ui所需的所有数据业务

// LoginRepository.kt (Service && Dao)

interface CustomerUserService {

    /**
     * 注册接口
     */
    @POST("/customerUser/register")
    fun customerRegister(@Body req: CustomerRegisterReq): Flowable<CustomerRegisterRsp>

    /**
     * 登录接口(手机验证码登录)
     */
    @POST("/customerUser/login")
    fun customerLogin(@Body req: CustomerLoginReq): Flowable<CustomerLoginRsp>

    /**
     * 验证码接口
     */
    @POST("/customerUser/captcha")
    fun customerCaptcha(@Body req: CustomerCaptchaReq): Flowable<CustomerCaptchaRsp>

    /**
     * 密码登录
     */
    @POST("/customerUser/loginPwd")
    fun customerLoginPwd(@Body req: LoginPwdReq): Flowable<CustomerCaptchaRsp>
}

interface UserInfoDao {
    @Query("SELECT * FROM userInfo WHERE id = :userId")
    fun getUser(userId: Int): LiveData<UserInfo>

    @Query("SELECT * FROM userInfo ORDER BY userInfo.changeTime DESC")
    fun getUserList(): LiveData<List<UserInfo>>

    @Query("SELECT EXISTS(SELECT 1 FROM userInfo WHERE id = :userId LIMIT 1)")
    fun hasUser(userId: Int): LiveData<Int>

    @Insert
    fun insertUserInfo(userinfo: UserInfo): Long

    @Query("DELETE FROM userInfo WHERE id = :userId")
    fun deleteUserInfo(userId: Int)

    @Update
    fun updateUserInfo(userinfo: UserInfo)
}

Service和Dao分别定义了数据获取的接口,直接通过简单的接口配置,就能实现各种各样的业务需求

//这里还有个不得不说的模块 就是di
// DatabaseModule.kt
@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {

    @Singleton
    @Provides
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return AppDatabase.getInstance(context)
    }

    @Provides
    fun provideUserInfoDao(appDatabase: AppDatabase): UserInfoDao {
        return appDatabase.userDao()
    }

}

// NetworkModule.kt
@InstallIn(SingletonComponent::class)
@Module
class NetworkModule {

    @Singleton
    @Provides
    fun providePageSearchService(): InfoSearchService {
        return HttpEngine.instance.create(InfoSearchService::class.java)
    }
}

这里单例初始化了Dao和Service

总结

这套结构模仿了sunflower的大致框架,使得项目更加减负,虽然大量的ui交互逻辑在Activity / fragment, 但是那属于复杂的需求,可以使用三方框架或是自己封装业务组件来减少代码,总体来说这套结构作为单人或是小团队开发,效率还是能得到保证的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容