得物登录组件重构
文/Dylan 得物技术
1.历史背景
登录模块对于一个App来说是十分重要的,其中稳定性和用户流畅体验更是重中之重,直接关乎到App用户的增长和留存。接手得物登录模块以后,我陆续发现了一些其中存在的问题,会导致迭代效率变低,稳定性也不能得到很好的保障。所以此次我将针对以上的问题,对登录模块进行升级改造。
2. 如何改造
通过梳理登录模块代码,发现的第一个问题就是登录页面种类样式比较多,但不同样式的登录页面的核心逻辑是基本类似的。但现有的代码做法是通过拷贝复制的方式,生成了一些不一样的页面,再分别做额外的差别处理。这种实现方式可能就只有一个优点,就是比较简单速度比较快,其余的应该都是缺点,特别是对于得物App来说,经常会有登录相关的迭代需求。
对于上述问题,该如何解决呢?通过分析发现,各不同类型的登录页面,不管是从功能还是UI设计上还是比较统一的,每个页面都可以分成若干个登录小组件,通过不同的小组件排列组合可以就是一个样式的登录页面了。因此我决定把登录页面中按照功能划分,把它拆分成一个个登录小组件,然后通过组合的方式去实现不同类型的登录页面,这样可以极大的组件的复用性,后续迭代也可以通过更多组合快速开发一个新的页面。这就是下面所要讲的模块化重构的由来。
2.1 模块化重构
2.1.1 目标
-
- 高复用
- 易扩展
- 维护简单
- 逻辑清晰,运行稳定
2.1.2 设计
为了实现上述目标,首先需要抽象出登录组件的概念component,实现一个component就代表一个登录小组件,它具备完整的功能。比如它可以是一个登录按钮,可以控制这个按钮的外观,点击事件,可点击状态等等。一个component如下:
其中key是这个组件的标识,代表这个组件的标识,主要用于组件间通讯。
loginScope是组件的一个运行时环境,通过loginScope可以管理页面,获取一些页面的公共配置,以及组件间的交互。lifecycle生命周期相关,由loginScope提供。cache是缓存相关。track为埋点相关,一般都是点击埋点。
loginScope提供componentStore,component通过组合的方式注册到componentStore统一管理。
componentStore通过key可以获取到对应的component组件,从而实现通信。
容器是所有component组件的宿主,也就是一个个页面,一般为activity和fragment,当然也可以是自定义。
2.1.3 实现
定义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 { private lateinit var delegate: ILoginScope 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 }
这里其实就是围绕activity或者fragment的代理调用封装,值得注意的是fragment我采用的是viewLifecyleOwner,保证了不会发生内存泄漏,又因为viewLifecyleOwner需要在特定生命周期获取,否则会发生异常,这里就利用包装类的形式定义了一个安全的SafeViewLifecycleOwner。
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 }
下面是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 <T : ILoginComponent> tryGet(key: ILoginComponent.Key<T>): T? { return componentArrays.find { it.key === key && it.loginScope.isEnable }?.let { @Suppress("UNCHECKED_CAST") it as? T? } } 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) { ... dispatch(loginScope) { xxxx } ... } } }
通过Array去保存注册进来的组件ILoginComponent,通过key可以遍历查找对应ILoginComponent组件,其中同一个key只能添加一个ILoginComponent,不能重复。再通过loginScope的loginLifecycleOwner监测host的生命周期变化,然后分发给各个ILoginComponent。
最后展现一个模块化重构后,使用组合的方式快速实现一个登录页面:
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中。此次独立登录业务将根据现有业务重新设计新的登录接口,更加清晰明了利于维护。
2.2.1 目标
- 接口设计职责明确
- 登录信息动态配置
- 登录路由页面降级能力
- 登录流程全程可感可知
- 多进程支持
- 登录引擎ab切换
2.2.2 设计
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? )
2.2.3 实现
整个组件的核心是LoginServiceImpl, 它实现ILoginModuleService接口去管理整个登录流程。为了保证用户体验,登录页面不会重复打开,所以正确维护登录状态特别重要。如何保证登录状态的正确呢?除了保证正确的业务逻辑,保证线程安全和进程安全是至关重要的。
(1)进程安全和线程安全
如何实现保证进程安全和线程安全?
这里利用了四大组件之一的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的兜底操作。
(2)跨进程分发事件
跨进程分发登录流程的状态和事件是通过ArbitraryIPCEvent实现的,后续可能会考虑开放出来。主要原理图如下:
(3)AB方案
因此次重构和独立组件化改动较大,所以设计一套可靠的AB方案是很有必要的。为了让AB方案更加简单可控,此次模块化代码只存在于新的登录组件中,原有的du_account的代码不变。AB中的A就运行原有的du_account中的代码,B则运行du_login中的代码,另外还要确保在一次完整的App生命周期内,AB的值不会发生变化,因为如果发生变化,代码就会变得不可控制。
因AB值需要依赖服务端下发,而登录有一些初始化的工作是在application初始化的过程,为了使得线上设备尽可能的按照下发的AB实验配置运行代码,所以对初始化操作进行了一个延后。主要策略就是,当application启动的时候不好立刻开始初始化,会先执行一个3s超时的定时器,如果在超时之前获取到AB下发值,则立刻初始化。如果超时后还没有获取到下发的ab配置,则立刻初始化,默认为A配置。如果在超时等待期间有任何登录代码被调用,则会立即先初始化。
2.2.4 使用
下面是在需要唤起登录页的地方,调用登录的一个例子。可以通过自由配置页面的样式,参数,降级策略,打开各种登录页面。
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) } } }
3.总结
此次登录重构改造之路比不是那么顺利的,其中也踩了许多坑,替换后也遇到了一些问题。以下是一些值得注意的地方:
- 首先在重构之前,要充分考虑所有使用到登录的业务,确保兼容现有所有业务,保证登录业务的稳定性。
- 要针对目前迭代中出现的存在的问题,充分思考需要做出哪些改变?
- 考虑登录业务可能迭代的方向,面向接口编程预留扩展接口,以防需求的频繁变更。
- 对于有跨进程的应用来说,要考虑进程安全和线程安全问题,需要保证在任何时候都能拿到最新的登录状态。
- 上线前做好AB方案,要做到两份代码充分解耦,尽量不要改原登录业务代码。
遇到的坑点:
比较费时的应该是fragment页面重建view id 的问题。
在测试不保留活动的case时,发现页面会变成空白,但是通过fragmentManger查询到的结果都是正常的(isAdded = true, isHided = false, isAttached = true)。排查了半天,突然想到了id问题,fragment的宿主containerView的id是我动态生成的,我没有使用xml写布局,是使用代码生成view的。
还有一个就是view onRestoreInstanceState的时机。
这个问题也是在测试不保留活动case遇到的,按常理只要view设置了id,Android的原生控件都会保留之前的状态,比如checkBox会保留勾选状态。我在fragment页面重建的onViewCreated方法中findViewById到了checkBox,但是通过isChecked获取到的值一直是false的,我百思不得其解,源代码也不要调试。后来通过对自定义控件ThirdLoginLayout实现保存状态能力的时候,通过调试发现onRestoreInstanceState回调时机比较靠后,在onViewCreated的时候view还没有把状态恢复过来。
埋点问题,因为我为了进程和线程安全,在登录过程中有创建了不可见的透明activity,由于刚开始登录状态校验都放在activity中,导致每次调用登录方法,必定会打开一个透明activity。这可能会影响上一个页面的曝光埋点,所以登录状态和前置条件检测(比如一键登录是否预取号成功,微信登录是否安装微信)不要放在透明activity中,并且做好状态的进程和线程安全。
*文/Dylan
关注得物技术,每周一三五晚18:30更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
限时活动:即日起至6月30日,公开转发得物技术任意一篇文章到朋友圈,就可以在「得物技术」公众号后台回复「得物」,参与得物文化衫抽奖。
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
告警消息何去何从?在飞书中飞起来
作者简介 袁振,SUSE Rancher 技术支持经理,负责订阅客户售后技术支持团队,为订阅客户提供技术支持服务。2016 年开始接触容器、Kubernetes 技术,对自动化运维、Devops、Kubernetes、prometheus 和其他云原生相关技术具有较深入的研究,对 SRE 运维体系建设、SRE 运维系统架构设计有着丰富的实践经验。 背景 通过《Prometheus 监控实战》这本书,我们认识到一个良好的监控系统应该提供以下服务: 全局视角,从最高层(业务)依次展开; 协助故障诊断; 作为基础设施、应用程序开发和业务人员的信息源; 内置于应用程序设计、开发和部署的生命周期中; 尽可能的自动化,并提供自服务。 拥有良好的监控系统还不够,我们还需要一个告警消息。告警消息可以为我们提供一些指示,表明我们环境中的某些状态已经发生了变化,而且通常是一些更糟糕的情况。好的告警消息的关键应该是能够在正确的时间、以正确的理由和正确的速度发送,并且其中应该包含有用且重要的信息。 告警消息发送的目的地也成为了其中一个关键环节。从传统的 E-mail、电话、短信通知到现在层出不穷的企业办公通信...
- 下一篇
CodeMirror 6.0 稳定版发布
CodeMirror 是一款浏览器端代码编辑器,基于 Javascript,短小精悍,实时在线代码高亮显示,他不是某个富文本编辑器的附属产品,他是许多大名鼎鼎的在线代码编辑器的基础库。如今它发布了6.0稳定版本,该版本是从头进行的重写,在性能和可维护性上都有诸多改善。 CodeMirror 6是一个新的Web代码编辑器库,是基于过去13年构建和维护1至5版本的经验而从头开始实现的。它的目标是比以前的版本更具有可扩展性和可访问性。 到今天为止,6.0版本已经稳定。今后,可能至少在几年内,所有的新版本都将在6大版本之下,并向后兼容。 这个库已经可用了一年多,而且基本稳定,只有小的破坏性变化。我一般喜欢晚点发布,以避免有太多令人遗憾的错误溜进稳定版,然后不得不无限期地保留在那里。毫无疑问,在一年后的今天,我希望我以不同的方式发布,但通过让用户在生产中使用代码相当长的时间,很多小问题和摩擦的来源都被发现和解决了,然后才被定下来。 这个系统的工作是在四年前开始的,由Prototype基金资助初始工作。在那之后的一年里,它被公开宣布并得到了众筹,在那之后的两年里,它被建成了一个可使用的系统,并在去...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
-
Docker使用Oracle官方镜像安装(12C,18C,19C)
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- CentOS8编译安装MySQL8.0.19
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- MySQL8.0.19开启GTID主从同步CentOS8
- CentOS7,8上快速安装Gitea,搭建Git服务器
- Jdk安装(Linux,MacOS,Windows),包含三大操作系统的最全安装
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果
推荐阅读
最新文章
- Hadoop3单机部署,实现最简伪集群
- SpringBoot2整合Thymeleaf,官方推荐html解决方案
- CentOS关闭SELinux安全模块
- CentOS8编译安装MySQL8.0.19
- CentOS7设置SWAP分区,小内存服务器的救世主
- 设置Eclipse缩进为4个空格,增强代码规范
- CentOS8安装MyCat,轻松搞定数据库的读写分离、垂直分库、水平分库
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池