写点什么

得物技术登录组件重构

作者:得物技术
  • 2022 年 3 月 30 日
  • 本文字数:11219 字

    阅读完需:约 37 分钟

1.历史背景

登录模块对于一个 App 来说是十分重要的,其中稳定性和用户流畅体验更是重中之重,直接关乎到 App 用户的增长和留存。接手得物登录模块以后,我陆续发现了一些其中存在的问题,会导致迭代效率变低,稳定性也不能得到很好的保障。所以此次我将针对以上的问题,对登录模块进行升级改造。

2. 如何改造

通过梳理登录模块代码,发现的第一个问题就是登录页面种类样式比较多,但不同样式的登录页面的核心逻辑是基本类似的。但现有的代码做法是通过拷贝复制的方式,生成了一些不一样的页面,再分别做额外的差别处理。这种实现方式可能就只有一个优点,就是比较简单速度比较快,其余的都应该是缺点,特别是对于得物 App 来说,经常会有登录相关的迭代需求。




对于上述问题,该如何解决呢?通过分析发现,各不同类型的登录页面,不管是从功能还是 ui 设计上还是比较统一的,每个页面都可以分成若干个登录小组件,通过不同的小组件排列组合可以就是一个样式的登录页面了。因此我决定把登录页面中按照功能划分,把它拆分成一个个登录小组件,然后通过组合的方式去实现不同类型的登录页面,这样可以极大的组件的复用性,后续迭代也可以通过更多组合快速开发一个新的页面。这就是下面所要讲的模块化重构的由来。

2.1 模块化重构

目标

  1. 高复用

  2. 易扩展

  3. 维护简单

  4. 逻辑清晰,运行稳定

设计

为了实现上述目标,首先需要抽象出登录组件的概念 component,实现一个 component 就代表一个登录小组件,它具备完整的功能。比如它可以是一个登录按钮,可以控制这个按钮的外观,点击事件,可点击状态等等。一个 component 如下,



其中 key 是这个组件的标识,代表这个组件的标识,主要用于组件间通讯。

loginScope 是组件的一个运行时环境,通过 loginScope 可以管理页面,获取一些页面的公共配置,以及组件间的交互。lifecycle 生命周期相关,由 loginScope 提供。cache 是缓存相关。track 为埋点相关,一般都是点击埋点。

loginScope 提供 componentStore,component 通过组合的方式注册到 componentStore 统一管理。



componentStore 通过 key 可以获取到对应的 component 组件,从而实现通信



容器是所有 component 组件的宿主,也就是一个个页面,一般为 activity 和 fragment,当然也可以是自定义。



实现

定义 ILoginComponent

interface ILoginComponent : FullLifecycleObserver, ActivityResultCallback {
val key: Key<*>
val loginScope: ILoginScope
interface Key<E : ILoginComponent>
}复制代码
复制代码

封装一个抽象的父组件,实现了默认的生命周期,需要一个 key 去标识这个组件,可以处理 onActivityResult 事件,并提供了一个默认的防抖 view 点击方法

open class AbstractLoginComponent(    override val key: ILoginComponent.Key<*>) : ILoginComponent {
companion object { private const val MMKV_LOGIN_KEY = "mmkv_key_****" }
private lateinit var delegate: ILoginScope
protected val localCache: MMKV by lazy { MMKV.mmkvWithID(MMKV_LOGIN_KEY, MMKV.MULTI_PROCESS_MODE) }
override val loginScope: ILoginScope get() = delegate
fun registerComponent(delegate: ILoginScope) { this.delegate = delegate loginScope.loginModelStore.registerLoginComponent(this) }
override fun onCreate() { }
...
override fun onDestroy() { }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { }}复制代码
复制代码

一个简单的组件实现,这是一个标题组件

class LoginBannerComponent(    private val titleText: TextView) : AbstractLoginComponent(LoginBannerComponent) {
companion object Key : ILoginComponent.Key<LoginBannerComponent>
override fun onCreate() { titleText.isVisible = true titleText.text = loginScope.param.title }}复制代码
复制代码

component 组件通常情况下并不关心视图长什么样,核心是处理组件的业务逻辑和交互。

根据登录业务梳理分析,组件的登录运行时环境 LoginRuntime,可以定义成如下这样

interface ILoginScope {
val loginModelStore: ILoginComponentModel
val loginHost: Any
val loginContext: Context?
var isEnable: Boolean
val param: LoginParam
val loginLifecycleOwner: LifecycleOwner
fun toast(message: String?)
fun showLoading(message: String? = null)
fun hideLoading()
fun close()
}复制代码
复制代码

这是一个场景的以 activity 或者 fragment 为宿主的组件运行时环境

class LoginScopeImpl : ILoginScope {
private var activity: AppCompatActivity? = null
private var fragment: Fragment? = null
override val loginModelStore: ILoginComponentModel
override val loginHost: Any get() = activity ?: requireNotNull(fragment)
override val param: LoginParam
constructor(owner: ILoginComponentModelOwner, activity: AppCompatActivity, param: LoginParam) { this.loginModelStore = owner.loginModelStore this.param = param this.activity = activity }
constructor(owner: ILoginComponentModelOwner, fragment: Fragment, param: LoginParam) { this.loginModelStore = owner.loginModelStore this.param = param this.fragment = fragment }
override val loginContext: Context? get() = activity ?: requireNotNull(fragment).context
override val loginLifecycleOwner: LifecycleOwner get() = activity ?: SafeViewLifecycleOwner(requireNotNull(fragment))
override var isEnable: Boolean = true
override fun toast(message: String?) { // todo toast }
override fun showLoading(message: String?) { // todo showLoading }
override fun hideLoading() { // todo hideLoading }
override fun close() { activity?.finish() ?: requireNotNull(fragment).also { if (it is IBottomAnim) { it.activity?.onBackPressedDispatcher?.onBackPressed() return } if (it is DialogFragment) { it.dismiss() } it.activity?.finish() } }
private class SafeViewLifecycleOwner(fragment: Fragment) : LifecycleOwner {
private val mLifecycleRegistry = LifecycleRegistry(this)
init { fun Fragment.innerSafeViewLifecycleOwner(block: (LifecycleOwner?) -> Unit) { viewLifecycleOwnerLiveData.value?.also { block(it) } ?: run { viewLifecycleOwnerLiveData.observeLifecycleForever(this) { block(it) } } }
fragment.innerSafeViewLifecycleOwner { if (it == null) { mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } else { it.lifecycle.addObserver(object : LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { mLifecycleRegistry.handleLifecycleEvent(event) } }) } } }
override fun getLifecycle(): Lifecycle = mLifecycleRegistry
}}复制代码
复制代码

这里其实就是围绕 activity 或者 fragment 的代理调用封装,值得注意的是 fragment 我采用的是 viewLifecyleOwner,保证了不会发生内存泄漏,又因为 viewLifecyleOwner 需要在特定生命周期获取,否则会发生异常,这里就利用包装类的形式定义了一个安全的 SafeViewLifecycleOwner。

下面是 ILoginComponentModel 接口,抽象了 componentStore 管理组件的方法

interface ILoginComponentModel {
fun registerLoginComponent(component: ILoginComponent)
fun unregisterLoginComponent(loginScope: ILoginScope)
fun <T : ILoginComponent> tryGet(key: ILoginComponent.Key<T>): T?
fun <T : ILoginComponent, R> callWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R?
operator fun <T : ILoginComponent> get(key: ILoginComponent.Key<T>): T
fun <T : ILoginComponent, R> requireCallWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R}复制代码
复制代码

这是具体的实现类,这里主要解决了 viewModelStore 保存和管理 viewmodel 的思想,还有 kotlin 协程通过 key 去获取 CoroutineContext 的思想去实现这个 componentStore,

class LoginComponentModelStore : ILoginComponentModel {
private var componentArrays: Array<ILoginComponent> = emptyArray()
private val lifecycleObserverMap by lazy { SparseArrayCompat<LoginScopeLifecycleObserver>() }
fun initLoginComponent(loginScope: ILoginScope, vararg componentArrays: ILoginComponent) { lifecycleObserverMap[System.identityHashCode(loginScope)]?.apply { componentArrays.forEach { initLoginComponentLifecycle(it) } } }
override fun registerLoginComponent(component: ILoginComponent) { component.loginScope.apply { if (loginLifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) { return } lifecycleObserverMap.putIfAbsentV2(System.identityHashCode(this)) { LoginScopeLifecycleObserver(this).also { loginLifecycleOwner.lifecycle.addObserver(it) } }.also { componentArrays = componentArrays.plus(component) it.initLoginComponentLifecycle(component) } } }
override fun unregisterLoginComponent(loginScope: ILoginScope) { lifecycleObserverMap.remove(System.identityHashCode(loginScope)) componentArrays = componentArrays.mapNotNull { if (it.loginScope === loginScope) { null } else { it } }.toTypedArray() }
override fun <T : ILoginComponent> tryGet(key: ILoginComponent.Key<T>): T? { return componentArrays.find { it.key === key && it.loginScope.isEnable }?.let { @Suppress("UNCHECKED_CAST") it as? T? } }
override fun <T : ILoginComponent, R> callWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R? { return tryGet(key)?.run(block) }
override fun <T : ILoginComponent> get(key: ILoginComponent.Key<T>): T { return tryGet(key) ?: throw IllegalStateException("找不到指定的ILoginComponent:$key") }
override fun <T : ILoginComponent, R> requireCallWithComponent(key: ILoginComponent.Key<T>, block: T.() -> R): R { return callWithComponent(key, block) ?: throw IllegalStateException("找不到指定的ILoginComponent:$key") }
private fun dispatch(loginScope: ILoginScope, block: ILoginComponent.() -> Unit) { componentArrays.forEach { if (it.loginScope === loginScope) { it.block() } } }
/** * ILoginComponent生命周期分发 **/ private inner class LoginScopeLifecycleObserver(private val loginScope: ILoginScope) : LifecycleEventObserver {
private var event = Lifecycle.Event.ON_ANY
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { this.event = event when (event) { Lifecycle.Event.ON_CREATE -> { dispatch(loginScope) { onCreate() } } Lifecycle.Event.ON_START -> { dispatch(loginScope) { onStart() } } Lifecycle.Event.ON_RESUME -> { dispatch(loginScope) { onResume() } } Lifecycle.Event.ON_PAUSE -> { dispatch(loginScope) { onPause() } } Lifecycle.Event.ON_STOP -> { dispatch(loginScope) { onStop() } } Lifecycle.Event.ON_DESTROY -> { dispatch(loginScope) { onDestroy() } loginScope.loginLifecycleOwner.lifecycle.removeObserver(this) unregisterLoginComponent(loginScope) } else -> throw IllegalArgumentException("ON_ANY must not been send by anybody") } } }
}复制代码
复制代码

最后展现一个模块化重构后,使用组合的方式快速实现一个登录页面

internal class FullOneKeyLoginFragment : OneKeyLoginFragment() {
override val eventPage: String = LoginSensorUtil.PAGE_ONE_KEY_LOGIN_FULL
override fun layoutId() = R.layout.fragment_module_phone_onekey_login
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState)
val btnClose = view.findViewById<ImageView>(R.id.btn_close) val tvTitle = view.findViewById<TextView>(R.id.tv_title) val thirdLayout = view.findViewById<ThirdLoginLayout>(R.id.third_layout) val btnLogin = view.findViewById<View>(R.id.btn_login) val btnOtherLogin = view.findViewById<TextView>(R.id.btn_other_login) val cbPrivacy = view.findViewById<CheckBox>(R.id.cb_privacy) val tvAgreement = view.findViewById<TextView>(R.id.tv_agreement)
loadLoginComponent( loginScope, LoginCloseComponent(btnClose), LoginBannerComponent(tvTitle), OneKeyLoginComponent(null, btnLogin, loginType), LoginOtherStyleComponent(thirdLayout), LoginOtherButtonComponent(btnOtherLogin), loginPrivacyLinkComponent(btnLogin, cbPrivacy, tvAgreement) ) }}复制代码
复制代码

一般情况下,只需要实现一个布局 xml 文件即可,如有特殊需求,也可以通过新增或者是继承复写组件实现。

2.2 登录单独组件化

登录业务逻辑进行重构之后,下一个目标就是把登录业务从 du_account 剥离出来,单独放在一个组件 du_login 中。此次独立登录业务将根据现有业务重新设计新的登录接口,更加清晰明了利于维护。

目标

  1. 接口设计职责明确

  2. 登录信息动态配置

  3. 登录路由页面降级能力

  4. 登录流程全程可感可知

  5. 多进程支持

  6. 登录引擎 ab 切换

设计

ILoginModuleService 接口设计,只暴露业务需要的方法。

interface ILoginModuleService : IProvider {
/** * 是否登录 */ fun isLogged(): Boolean
/** * 打开登录页,一般kotlin使用 * @return 返回此次登录唯一标识 */ @MainThread fun showLoginPage(context: Context? = null, builder: (LoginBuilder.() -> Unit)? = null): String
/** * 打开登录页,一般java使用 * @return 返回此次登录唯一标识 */ @MainThread fun showLoginPage(context: Context? = null, builder: LoginBuilder): String
/** * 授权登录,一般人用不到 */ fun oauthLogin(activity: Activity, authModel: OAuthModel, cancelIfUnLogin: Boolean)
/** * 用户登录状态liveData,支持跨进程 */ fun loginStatusLiveData(): LiveData<LoginStatus>
/** * 登录事件liveData,支持跨进程 */ fun loginEventLiveData(): LiveData<LoginEvent>
/** * 退出登录 */ fun logout()}复制代码
复制代码

登录参数配置

class NewLoginConfig private constructor(    val styles: IntArray,    val title: String,    val from: String,    val tag: String,    val enterAnimId: Int,    val exitAnimId: Int,    val flag: Int,    val extra: Bundle?) 复制代码
复制代码

支持按优先级顺序配置多种样式的登录页面,路由失败会自动降级

支持追溯登录来源,利于埋点

支持配置页面打开关闭动画

支持配置自定义参数 Bundle

支持跨进程观察登录状态变化

internal sealed class LoginStatus {
object UnLogged : LoginStatus()
object Logging : LoginStatus()
object Logged : LoginStatus()}复制代码
复制代码

支持跨进程感知登录流程

/** * [type] * -1 打开登录页失败,不满足条件 * 0 cancel * 1 logging * 2 logged * 3 logout * 4 open第一个登录页 * 5 授权登录页面打开 */class LoginEvent constructor(    val type: Int,    val key: String,    val user: UsersModel?)复制代码
复制代码

实现

整个组件的核心是 LoginServiceImpl, 它实现 ILoginModuleService 接口去管理整个登录流程。为了保证用户体验,登录页面不会重复打开,所以正确维护登录状态特别重要。如何保证登录状态的正确呢?除了保证正确的业务逻辑,保证线程安全和进程安全是至关重要的。

进程安全和线程安全

如何实现保证进程安全和线程安全?

这里利用了四大组件之一的 Activity 去实现,进程安全和线程安全。LoginHelperActivity 是一个透明看不见的 activity。

<activity    android:name=".LoginHelperActivity"    android:label=""    android:launchMode="singleInstance"    android:screenOrientation="portrait"    android:theme="@style/TranslucentStyle" />复制代码
复制代码

LoginHelperActivity 的主要就是利用它的线程安全进程安全的特性,去维护登录流程,防止重复打开登录页面,打开执行完逻辑以后就立刻关闭。它的启动模式是 singleInstance,单独存在一个任务栈,即开即关,在任何时候启动都不会影响登录流程,还能很好解决跨进程和线程安全的问题。退出登录也是利用 LoginHelperActivity 去实现的,也是利用了线程安全跨进程的特性,保证状态不会出错。

internal companion object {    internal const val KEY_TYPE = "key_type"        internal fun login(context: Context, newConfig: NewLoginConfig) {        context.startActivity(Intent(context, LoginHelperActivity::class.java).also {            if (context !is Activity) {                it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)            }            it.putExtra(KEY_TYPE, 0)            it.putExtra(NewLoginConfig.KEY, newConfig)        })    }        internal fun logout(context: Context) {        context.startActivity(Intent(context, LoginHelperActivity::class.java).also {            if (context !is Activity) {                it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)            }            it.putExtra(KEY_TYPE, 1)        })    }}

override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (isFinishing) { return } try { if (intent?.getIntExtra(KEY_TYPE, 0) == 0) { tryOpenLoginPage() } else { loginImpl.logout() } } catch (e: Exception) { } finally { finish() }}复制代码
复制代码

登录逻辑打开的也是一个辅助的 LoginEntryActivity,也是一个透明看不见的,它的启动模式是 singleTask 的,它将作为所有登录流程的根 Activity,会伴随整个登录流程一直存在,特殊情况除外(比如不保留活动模式,进程被杀死,内存不足),LoginEntryActivity 的销毁代表着登录流程的结束(特殊情况除外)。在 LoginEntryActivity 的 onResume 生命周期才会路由到真正的登录页面,为了防止意外情况发生,路由的同时会开启一个超时检测,防止真正的登录页面无法打开,导致一直停留在 LoginEntryActivity 界面导致界面无响应的问题。

<activity    android:name=".LoginEntryActivity"    android:label=""    android:launchMode="singleTask"    android:screenOrientation="portrait"    android:theme="@style/TranslucentStyle" />
internal companion object { private const val SAVE_STATE_KEY = "save_state_key"
internal fun login(activity: Activity, extra: Bundle?) { activity.startActivity(Intent(activity, LoginEntryActivity::class.java).also { if (extra != null) { it.putExtras(extra) } }) }
/** * 结束登录流程,一般用于登录成功 */ internal fun finishLoginFlow(activity: LoginEntryActivity) { activity.startActivity(Intent(activity, LoginEntryActivity::class.java).also { it.putExtra(KEY_TYPE, 2) }) }}复制代码
复制代码

通过 registerActivityLifecycleCallbacks 感知 activity 生命周期变化,用于观察登录流程开始和结束,以及登录流程的异常退出。像是其他业务通过 registerActivityLifecycleCallbacks 获取 LoginEntryActivity 后主动 finish 的行为,是会被感知到的,然后退出登录流程的。

登录流程的结束也是利用了 singleTask 的特性去销毁所有的登录页面,这里还有一个小细节是为了防止如不保留活动的异常情况,LoginEntryActivity 被提前销毁,可能就没办法利用 singleTask 特性去销毁其他页面,所有还是有一个主动缓存 activity 的兜底操作。

跨进程分发事件

跨进程分发登录流程的状态和事件是通过 ArbitraryIPCEvent 实现的,后续可能会考虑开放出来。主要原理图如下:



ab 方案

因此次重构和独立组件化改动较大,所以设计一套可靠的 ab 方案是很有必要的。为了让 ab 方案更加简单可控,此次模块化代码只存在于新的登录组件中,原有的 du_account 的代码不变。ab 中的 a 就运行原有的 du_account 中的代码,b 则运行 du_login 中的代码,另外还要确保在一次完整的 app 生命周期内,ab 的值不会发生变化,因为如果发生变化,代码就会变得不可控制。因 ab 值需要依赖服务端下发,而登录有一些初始化的工作是在 application 初始化的过程,为了使得线上设备尽可能的按照下发的 ab 实验配置运行代码,所以对初始化操作进行了一个延后。主要策略就是,当 application 启动的时候不好立刻开始初始化,会先执行一个 3s 超时的定时器,如果在超时之前获取到 ab 下发值,则立刻初始化。如果超时后还没有获取到下发的 ab 配置,则立刻初始化,默认为 a 配置。如果在超时等待期间有任何登录代码被调用,则会立即先初始化。


使用

ServiceManager.getLoginModuleService().showLoginPage(activity) {    withStyle(*LoginBuilder.transformArrayByStyle(config))    withTitle(config.title)    withFrom(config.callFrom)    config.tag?.also {        withTag(it)    }    config.extra?.also {        if (it is Bundle) {            withExtra(it)        }    }}
复制代码


if (LoginABTestHelper.INSTANCE.getAbApplyLoginModule()) {    LoginBuilder builder = new LoginBuilder();    builder.withTitle(LoginHelper.LoginTipsType.TYPE_NEW_USER_RED_PACKET.getType());    if (LoginHelper.abWechatOneKey) {        builder.withStyle(LoginStyle.HALF_RED_TECH, LoginStyle.HALF_WECHAT);    } else {        builder.withStyle(LoginStyle.HALF_RED_TECH);    }    builder.addFlag(LoginBuilder.FLAG_FORCE_PRE_VERIFY_IF_NULL);    Bundle bundle = new Bundle();    bundle.putString("url", imageUrl);    bundle.putInt("popType", data.popType);    builder.withExtra(bundle);    builder.withHook(() -> fragmentManager.isResumed() && !fragmentManager.isHidden());        final String tag = ServiceManager.getLoginModuleService().showLoginPage(context, builder);    LiveData<LoginEvent> liveData = ServiceManager.getLoginModuleService().loginEventLiveData();    liveData.removeObservers(fragmentManager);    liveData.observe(fragmentManager, loginEvent -> {        if (!TextUtils.equals(tag, loginEvent.getKey())) {            return;        }        if (loginEvent.getType() == -1) {            //利益点弹窗弹出失败的话,弹新人弹窗            afterLoginFailedPop(fragmentManager, data, dialogDismissListener);        } else if (loginEvent.getType() == 2) {            if (TextUtils.isEmpty(finalRouterUrl)) return;            Navigator.getInstance().build(finalRouterUrl).navigation(context);        }        if (loginEvent.isEndEvent()) {            liveData.removeObservers(fragmentManager);        }    });}
复制代码


开发中遇到的坑点

1、比较费时的应该是 fragment 页面重建 view id 的问题。

在测试不保留活动的 case 时,发现页面会变成空白,但是通过 fragmentManger 查询到的结果都是正常的(isAdded = true, isHided = false, isAttached = true)。排查了半天,突然想到了 id 问题,fragment 的宿主 containerView 的 id 是我动态生成的,我没有使用 xml 写布局,是使用代码生成 view 的。

2、还有一个就是 view onRestoreInstanceState 的时机

这个问题也是在测试不保留活动 case 遇到的,按常理只要 view 设置了 id,Android 的原生控件都会保留之前的状态,比如 checkBox 会保留勾选状态。我在 fragment 页面重建的 onViewCreated 方法中 findViewById 到了 checkBox,但是通过 isChecked 获取到的值一直是 false 的,我百思不得其解,源代码也不要调试。后来通过对自定义控件 ThirdLoginLayout 实现保存状态能力的时候,通过调试发现 onRestoreInstanceState 回调时机比较靠后,在 onViewCreated 的时候 view 还没有把状态恢复过来。


文/Dylan

关注得物技术,做最潮技术人!

发布于: 刚刚阅读数: 2
用户头像

得物技术

关注

得物APP技术部 2019.11.13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
得物技术登录组件重构_重构_得物技术_InfoQ写作平台