1.歷史背景
登入模組對於一個App來說是十分重要的,其中穩定性和使用者流暢體驗更是重中之重,直接關乎到App使用者的增長和留存。接手得物登入模組以後,我陸續發現了一些其中存在的問題,會導致迭代效率變低,穩定性也不能得到很好的保障。所以此次我將針對以上的問題,對登入模組進行升級改造。
2. 如何改造
通過梳理登入模組程式碼,發現的第一個問題就是登入頁面種類樣式比較多,但不同樣式的登入頁面的核心邏輯是基本類似的。但現有的程式碼做法是通過拷貝複製的方式,生成了一些不一樣的頁面,再分別做額外的差別處理。這種實現方式可能就只有一個優點,就是比較簡單速度比較快,其餘的都應該是缺點,特別是對於得物App來說,經常會有登入相關的迭代需求。
對於上述問題,該如何解決呢?通過分析發現,各不同型別的登入頁面,不管是從功能還是ui設計上還是比較統一的,每個頁面都可以分成若干個登入小元件,通過不同的小元件排列組合可以就是一個樣式的登入頁面了。因此我決定把登入頁面中按照功能劃分,把它拆分成一個個登入小元件,然後通過組合的方式去實現不同型別的登入頁面,這樣可以極大的元件的複用性,後續迭代也可以通過更多組合快速開發一個新的頁面。這就是下面所要講的模組化重構的由來。
2.1 模組化重構
目標
- 高複用
- 易擴充套件
- 維護簡單
- 邏輯清晰,執行穩定
設計
為了實現上述目標,首先需要抽象出登入元件的概念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中。此次獨立登入業務將根據現有業務重新設計新的登入介面,更加清晰明瞭利於維護。
目標
- 介面設計職責明確
- 登入資訊動態配置
- 登入路由頁面降級能力
- 登入流程全程可感可知
- 多程式支援
- 登入引擎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
關注得物技術,做最潮技術人!