0%

关于优化UI层的代码设计,从MVC,MVP,MVVM再到MVI,这四个模式围绕如何管理UI问题采用的都是“关注点分离”,实现细节稍有不同,但最终目的仍是解耦,提高维护性和可读性,从架构层面明确职责划分,约束开发者写出漂亮的代码,这里先回顾一下Android UI架构的演进过程与自己的一些思考并详细记录MVI的具体实现以及解决的相关痛点问题,欢迎一起学习讨论~

UI架构演进

MVC

Android的默认设计,最原始的UI架构,拆分为View,Model,Controller
View:布局xml文件
Model:管理业务数据逻辑,网络请求,数据库处理
Controller: 在Activity处理表现层逻辑

效果图

  • 存在问题
    Activity臃肿,本身不可避免的要处理UI和用户交互,又要处理表现层逻辑,职责划分不清晰,分离程度不够

MVP

为了减轻Activity的负担,进化到了MVP架构,拆分为View,Model,Presenter
View:Activity和布局xml文件
Model:管理业务数据逻辑,网络请求,数据库处理,职责不变
Presenter:分担Activity一部分工作,处理表现层逻辑,其中View和Presenter会定义协议接口Contract,约定View调Presenter发送指令的接口方法和Presenter调callback返回结果给View的接口方法

效果图

  • 存在问题
  • 协议接口Contract膨胀
    当交互负责时会定义很多的发送指令和callback回调接口方法,不好维护
  • 内存泄漏
    生命周期无法绑定,Presenter持有View层的引用,当View层被关闭销毁时Model层有耗时操作存在内存泄漏的风险,当然可以在onDestory的生命周期中释放Presenter并采用弱引用的方式,但处理起来比较繁琐
  • 双向依赖
    View层有变动时Presenter也需要做出相应的调整,在实际开发中View层是容易变动的,一定程度上影响开发效率

MVVM

为了解决以上的三个问题,进化到MVVM的架构,把Presenter改为ViewModel
View:Activity和布局xml文件
Model:管理业务数据逻辑,网络请求,数据库处理,职责不变
ViewModel:存储UI状态,处理表现层逻辑,将数据返回给观察者更新UI,这里View和Presenter的双向依赖变为View向ViewModel发指令,但ViewModel不直接回调返回结果而是通过观察者模式利用LiveData监听数据变化,返回给相应的观察者更新UI,解决了生命周期感知问题,同时也解决了手机旋转等配置变更数据丢失问题

效果图

  • 存在问题
  • 多数据流
    View与ViewModel的交互分散,不易于追踪
  • LiveData膨胀
    复杂页面交互需要定义多个LiveData,模糊了状态和事件的界限,粘性机制可能会导致一些问题,用在UI状态时如横竖屏切换时会重新执行一次observe会再次收到一个事件,对于UI来说只需关心最终状态,多次postValue可能会丢失数据也可能导致一些问题,用在事件发送时可能只执行第二个事件,对于事件来说希望每条事件都能被执行

MVI

是什么

把View和ViewModel之间的多数据流改为基于ViewState和Intent的单数据流替换LiveData
View:Activity和布局xml文件
Model:管理业务数据逻辑,网络请求,数据库处理,职责不变
Intent:操作事件,将UI层的操作事件和携带的数据传到Model
ViewState:数据类,包含页面状态和对应的数据
ViewModel:存储UI状态,处理表现层逻辑,通过ViewState设置给UI

效果图

解决痛点

  • 单一数据流便于追踪问题
  • 清晰的划分状态和事件的界限,避免LiveData滥用导致的问题

    MVI封装流程

声明State和Intent接口

所有的业务层UiState都需要实现IState接口,UiIntent都需要实现IIntent接口

1
2
3
interface IState

interface IIntent

声明业务层ViewModel基类接口

业务层ViewModel基类需要初始化stateFlow和intentFlow

1
2
3
4
interface IStateViewModel<I: IIntent, S: IState> {
val stateFlow: Flow<S>
val intentFlow: Flow<I>
}

封装View层生命周期事件

初始化默认为IDLE空闲状态

1
2
3
4
5
6
enum class WidgetLifeEvent {
IDLE, // 空闲
ON_SHOW, // 显示
ON_HIDE, // 隐藏
ON_DESTROY // 销毁
}

封装观察者接口

观察者可通过回调监测到生命周期变化

1
2
3
4
5
interface WidgetLifecycleObserver {
fun onShow()
fun onHide()
fun onDestroy()
}

生命周期观察者管理类

  • 生命周期观察者被存储到一个列表中,当前的生命周期默认为IDLE
  • onShow/onHide/onDestroy事件会更新当前的生命周期状态,遍历所有的观察者调对应的接口方法
  • onDestroy时会清空列表所有的观察者
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    class WidgetLifecycle {
    var currentWidgetLifeEvent: WidgetLifeEvent = WidgetLifeEvent.IDLE // 当前的生命周期

    private val widgetLifeList = mutableListOf<WidgetLifecycleObserver>()

    /**
    * 添加生命周期观察者
    */
    fun addWidgetLife(widgetLifecycleObserver: WidgetLifecycleObserver){
    synchronized(widgetLifeList){
    if (widgetLifecycleObserver !in widgetLifeList){
    widgetLifeList.add(widgetLifecycleObserver)
    }
    }
    }

    /**
    * 移除生命周期观察者
    */
    fun removeWidgetLife(widgetLifecycleObserver: WidgetLifecycleObserver){
    synchronized(widgetLifeList){
    widgetLifeList.remove(widgetLifecycleObserver)
    }
    }

    /**
    * 调所有观察者的onShow
    */
    fun onShow(){
    synchronized(widgetLifeList){
    currentWidgetLifeEvent = WidgetLifeEvent.ON_SHOW
    widgetLifeList.onEach {
    kotlin.runCatching {
    it.onShow()
    }.onFailure {
    "WidgetLifecycleObserver onShow异常".logD()
    }
    }
    }
    }

    /**
    * 调所有观察者的onHide
    */
    fun onHide(){
    synchronized(widgetLifeList){
    currentWidgetLifeEvent = WidgetLifeEvent.ON_HIDE
    widgetLifeList.onEach {
    kotlin.runCatching {
    it.onHide()
    }.onFailure {
    "WidgetLifecycleObserver onHide异常".logD()
    }
    }
    }
    }

    /**
    * 调所有观察者的onDestory
    */
    fun onDestroy(){
    synchronized(widgetLifeList){
    currentWidgetLifeEvent = WidgetLifeEvent.ON_DESTROY
    widgetLifeList.forEach {
    kotlin.runCatching {
    it.onDestroy()
    }.onFailure {
    "WidgetLifecycleObserver onDestroy异常".logD()
    }
    }
    widgetLifeList.clear()
    "widgetLifeList clear".logD()
    }
    }
    }

    暴露生命周期观察者管理对象

    实现了WidgetLifecycleOwner接口的类会提供命周期观察者管理对象
    1
    2
    3
    interface WidgetLifecycleOwner {
    fun getWidgetLifecycle(): WidgetLifecycle
    }

    封装BaseViewModel

    继承ViewModel的同时实现了WidgetLifecycleOwner接口,提供生命周期观察者管理对象,BaseStateFragment通过channel分发生命周期变化事件给BaseViewModel,BaseViewModel再把对应的事件分发给所有的观察者
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    abstract class BaseViewModel : ViewModel(), WidgetLifecycleOwner{
    private val widgetLifecycle = WidgetLifecycle() // 生命周期观察者管理对象
    private val lifeEventChannel: Channel<WidgetLifeEvent> = Channel()
    private val lifecycleFlow: Flow<WidgetLifeEvent> = lifeEventChannel.consumeAsFlow()

    override fun getWidgetLifecycle(): WidgetLifecycle {
    return widgetLifecycle
    }

    protected fun launch(callback: suspend () -> Unit){
    viewModelScope.launch {
    callback.invoke()
    }
    }

    init {
    viewModelScope.launch {
    lifecycleFlow.collect{
    if (it == WidgetLifeEvent.ON_SHOW){
    onShow()
    }
    if (it == WidgetLifeEvent.ON_HIDE){
    onHide()
    }
    }
    }
    }

    /**
    * 发送生命周期事件
    */
    fun sendLifeEvent(widgetLifeEvent: WidgetLifeEvent){
    launch {
    lifeEventChannel.send(widgetLifeEvent)
    }
    }

    open fun onShow(){
    widgetLifecycle.onShow()
    "onShow".logD()
    }

    open fun onHide(){
    widgetLifecycle.onHide()
    "onHide".logD()
    }

    override fun onCleared() {
    super.onCleared()
    widgetLifecycle.onDestroy()
    "onCleared".logD()
    }
    }

    封装加载视图和toast事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    sealed class SingleEvent {
    companion object {
    const val LOADING_TYPE_DIALOG = 1
    const val LOADING_TYPE_VIEW = 2
    }

    object HideLoadingDialog: SingleEvent()
    object ShowLoadingDialog: SingleEvent()
    object ShowLoadingView: SingleEvent()
    object ShowContentView: SingleEvent()
    object HideSwipeLayout: SingleEvent()

    data class ShowToast(val msg: String): SingleEvent()
    }

    封装BaseStateViewModel

  • 继承BaseViewModel,实现IStateViewModel接口
  • 需要传入意图和视图状态的具体类型
  • 初始化intentFlow和stateFlow,intentFlow通过channel转换实现,stateFlow需要子类返回具体状态类,初始化状态对象再初始化StateFlow实现
  • 加载视图和toast事件通过singleEventChannel发送,在BaseStateFragment中绑定事件并处理视图展示逻辑
  • 更新State时刷新stateFlow的value,比较当前stateFlow的value是否和需要更新的State一致,如果一致则不会发送事件,因为StateFlow具有数据防抖功能
  • 根据ViewModel的scope封装子线程切换和flow的子线程切换方法
  • 声明dispatchIntentOnIO抽象方法,在子线程中分发意图事件并处理,不阻塞主线程,避免UI卡顿
  • 封装加载状态展示和自动隐藏的方法,通过singleEventChannel发送相应的加载视图事件
  • 封装checkIntent优化switch判断类型逻辑便于链式回调
  • 初始化代码块中绑定intentFlow切换到子线程分发事件
  • dispatchIntentOnIO中的异常在needCatchException为true且release包下会被catch住
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    abstract class BaseStateViewModel<I: IIntent, S: IState>: BaseViewModel(), IStateViewModel<I,S> {
    // Channel发送Intent
    private val intentChannel = Channel<I> (Channel.UNLIMITED)
    final override val intentFlow: Flow<I>
    get() = intentChannel.consumeAsFlow()

    protected abstract val stateClass: KClass<S> // 状态类

    open val needCatchException = false // 是否需要try/catch

    // stateflow发送state
    private val initState = createInitState() // 状态对象初始化

    private val mutableStatedFlow = MutableStateFlow(initState) // stateFlow初始化

    /**
    * 可变stateflow转成只读流
    */
    override val stateFlow: Flow<S>
    get() = mutableStatedFlow.asStateFlow()

    private val singleEventChannel = Channel<SingleEvent>()

    protected val tag: String get() = javaClass.name

    /**
    * SingleEvent flow
    */
    val singleEvent get() = singleEventChannel.consumeAsFlow()

    /**
    * 发送intent
    */
    fun sendIntent(intent: I){
    viewModelScope.launch {
    intentChannel.send(intent)
    }
    }

    /**
    * 获取当前state对象
    */
    val state: S get() = mutableStatedFlow.value

    @Synchronized
    private fun emitState(state: S){
    (if (state == this.state) "界面不会刷新" else "界面【会刷新】").apply {
    "$tag -> 发送状态 -> ${state.javaClass.name} $this".logD()
    if (isDebug()){
    "$tag->发送状态数据->$this 旧数据:${this@BaseStateViewModel.state} 新数据:${state}".logD()
    }
    }
    mutableStatedFlow.value = state
    }

    /**
    * 更新state
    */
    protected fun updateState(reducer: S.() -> S){
    emitState(state.reducer())
    }

    /**
    * 开启子线程执行
    */
    protected fun launchOnIO(callback: suspend () -> Unit): Job{
    return viewModelScope.launch(Dispatchers.IO) { callback.invoke() }
    }

    /**
    * flow切换子线程执行
    */
    private fun <T> Flow<T>.collectOnIO(block: suspend CoroutineScope.(T) -> Unit){
    viewModelScope.launch(Dispatchers.IO){
    this@collectOnIO.onEach {
    block.invoke(this, it)
    }.launchIn(this)
    }
    }

    /**
    * 在IO协程分发Intent,这个方法只会在IO协程里面执行
    * 非阻塞的,不影响其他intent
    */
    protected abstract suspend fun dispatchIntentOnIO(intent: I)


    /**
    * 初始化默认状态,子类可以实现自己的默认状态,比如读取缓存之类的
    */
    protected open fun createInitState(): S{
    return stateClass.createInstance()
    }

    /**
    * 发送一个单一事件
    */
    protected fun sendSingleEvent(singleEvent: SingleEvent){
    launch {
    singleEventChannel.send(singleEvent)
    }
    }

    protected fun showLoadingDialog(){
    sendSingleEvent(SingleEvent.ShowLoadingDialog)
    }

    protected fun hideLoadingDialog(){
    sendSingleEvent(SingleEvent.HideLoadingDialog)
    }

    protected fun showLoadingView(){
    sendSingleEvent(SingleEvent.ShowLoadingView)
    }

    protected fun showContentView(){
    sendSingleEvent(SingleEvent.ShowContentView)
    }

    protected fun showToast(msg: String){
    sendSingleEvent(SingleEvent.ShowToast(msg))
    }

    /**
    * 显示加载状态
    * @param type 加载状态类型 对话框 加载View
    * @param needAutoHide 是否需要自动触发隐藏
    */
    protected suspend fun <T> Flow<T>.startLoading(
    type: Int = SingleEvent.LOADING_TYPE_DIALOG,
    needAutoHide: Boolean = true
    ): Flow<T>{
    return this.onStart {
    if (type == SingleEvent.LOADING_TYPE_DIALOG){
    singleEventChannel.send(SingleEvent.ShowLoadingDialog)
    } else {
    singleEventChannel.send(SingleEvent.ShowLoadingView)
    }
    }.onCompletion {
    if (needAutoHide){
    if (type == SingleEvent.LOADING_TYPE_DIALOG){
    singleEventChannel.send(SingleEvent.HideLoadingDialog)
    } else {
    singleEventChannel.send(SingleEvent.ShowContentView)
    }
    }
    }
    }

    /**
    * 加载对话框
    */
    protected suspend fun <T> Flow<T>.startLoadingDialog(): Flow<T> {
    return startLoading(SingleEvent.LOADING_TYPE_DIALOG)
    }

    protected suspend fun <T> Flow<T>.startLoadingView(): Flow<T>{
    return startLoading(SingleEvent.LOADING_TYPE_VIEW)
    }

    protected suspend fun <T> Flow<T>.startLoadingViewWithCondition(
    needShowLoadingView: Boolean = true
    ): Flow<T> {
    return if (needShowLoadingView) startLoading(SingleEvent.LOADING_TYPE_VIEW) else this
    }

    protected suspend fun <T> Flow<T>.startLoadingByCondition(
    needShowLoading: Boolean = true,
    type: Int = SingleEvent.LOADING_TYPE_VIEW
    ): Flow<T> {
    return if (needShowLoading) startLoading(type) else this
    }

    /**
    * 判断Intent类型,如果是该类型就回调
    *
    * @param T
    * @param block
    * @receiver
    */
    protected inline fun <reified T: IIntent> IIntent.checkIntent(
    block: (T) -> Unit
    ): IIntent{
    if (this is T){
    block.invoke(this)
    }
    return this
    }



    init {
    collectIntent()
    }

    /**
    * 异步订阅Intent,所有的intent的执行都是在IO协程里面执行
    *
    */
    private fun collectIntent() {
    intentFlow.collectOnIO {
    val intent = it
    val intentName = intent.javaClass.name
    "$tag->分发Intent->$intentName".logD()
    "$tag->分发Intent详细数据->$it".logD()

    callbackByCondition {
    launch {
    dispatchIntentOnIO(it)
    "异步分发Intent完成->$intentName".logD()
    }
    }
    }
    }

    protected open fun isRelease(): Boolean{
    return !BuildConfig.DEBUG
    }

    protected open fun isDebug(): Boolean {
    return BuildConfig.DEBUG
    }

    /**
    *
    * release环境下,needCatchException=true会捕获所有intent异常
    * @param block
    * @receiver
    */
    private suspend fun callbackByCondition(block: suspend () -> Unit) {
    if (needCatchException && isRelease()) {
    kotlin.runCatching {
    block.invoke()
    }.onFailure {
    "callbackByCondition异常".logD()
    }
    } else {
    block.invoke()
    }
    }
    }

    封装BaseFragment

  • 对加载视图进行封装,把包含layout的content视图包裹在加载视图中
  • 提供显示空视图,错误视图,内容视图,弹出toast,展示加载弹窗和隐藏加载弹窗的方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    abstract class BaseFragment : Fragment() {
    private var loadingView: WrapperLoadingView? = null
    private var loadingDialogFragment: LoadingDialogFragment? = null
    open var needLoadingView = true

    /**
    * 获取layout资源
    *
    * @return
    */
    @LayoutRes
    protected abstract fun getLayout(): Int


    /**
    * 初始化加载弹窗和加载视图
    */
    final override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
    ): View? {
    if (loadingDialogFragment == null) {
    loadingDialogFragment = LoadingDialogFragment()
    }
    return if (needLoadingView) {
    if (loadingView == null) {
    loadingView = WrapperLoadingView(
    inflater.context,
    inflater.inflate(getLayout(), container, false)
    )
    } else {
    (loadingView?.parent as? ViewGroup)?.removeView(loadingView)
    }
    loadingView
    } else {
    inflater.inflate(getLayout(), container, false)
    }
    }

    fun showContentView(){
    loadingView?.showContent()
    }

    fun showLoadingView(@ColorInt color: Int = Color.TRANSPARENT){
    loadingView?.showLoading(Gravity.CENTER, color)
    }

    fun showLoadingDialog(){
    loadingDialogFragment?.show(childFragmentManager,"loading_dialog")
    }

    fun hideLoadingDialog(){
    loadingDialogFragment?.dismiss()
    }

    fun showEmptyView(
    tips: Int = R.string.no_more_data,
    icon: Int = R.mipmap.icon_empty_default,
    click: View.OnClickListener? = null
    ) {
    loadingView?.showEmpty(tips, icon, click)
    }

    fun showEmptyView(
    tips: String,
    icon: Int = R.mipmap.icon_empty_default,
    click: View.OnClickListener? = null
    ) {
    loadingView?.showEmpty(tips, icon, click)
    }

    fun showFailView(
    tips: Int = R.string.load_failed_click_retry,
    icon: Int = R.mipmap.icon_error_retry,
    click: View.OnClickListener? = null
    ) {
    loadingView?.showEmpty(tips, icon, click)
    }

    fun showFailView(
    tips: String,
    icon: Int = R.mipmap.icon_empty_default,
    click: View.OnClickListener? = null
    ) {
    loadingView?.showEmpty(tips, icon, click)
    }

    fun showErrorView(tips: String, click: View.OnClickListener? = null) {
    loadingView?.showError(tips, click)
    }

    fun showErrorView(tips: String, color: Int, click: View.OnClickListener? = null) {
    loadingView?.showError(tips, color, click)
    }

    fun showToast(errorMsg: String?) {
    ToastUtils.showShort(errorMsg)
    }

    fun getViewProvider(): View {
    return if (needLoadingView){
    (view as WrapperLoadingView).contentView
    } else requireView()
    }
    }

    封装BaseStateFragment

  • 继承BaseFragment,子类需要传入具体的意图类型,视图状态类型,绑定的ViewModel的类型
  • 懒初始化ViewModelProvider的Factory,暴露出方法给子类重写默认返回空,暴露出方法给子类返回具体的ViewModel的Class对象
  • 暴露绑定state和初始化View的方法,在onViewCreated中调用
  • 绑定singleEventChannel并处理对应的UI事件
  • 在onResume和onPause中发送对应的生命周期事件给生命周期观察者
  • stateFlow绑定具体的state属性,在生命周期started后回调变更后的state,distinctUntilChanged避免多次不必要的刷新
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    abstract class BaseStateFragment<I : IIntent, S : IState, VM : BaseStateViewModel<I, S>> :
    BaseFragment() {
    private val vmFactory: ViewModelProvider.Factory? by lazy { getViewModelFactory() }
    protected abstract val viewModelClass: KClass<VM>

    /**
    * 如果ViewModelProvider.Factory不为空则用它初始化否则直接初始化
    */
    private val viewModel: VM by lazy {
    vmFactory?.run {
    ViewModelProvider(this@BaseStateFragment, this)[viewModelClass.java]
    } ?: ViewModelProvider(this@BaseStateFragment)[viewModelClass.java]
    }

    /**
    * ViewModelProvider.Factory,如果需要可以自行实现
    * @return
    */
    protected open fun getViewModelFactory(): ViewModelProvider.Factory? {
    return null
    }

    /**
    * fragment布局创建完成
    * 绑定state
    * 绑定SingleEvent并处理
    * 初始化View
    */
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    bindState()
    handleSingleEvent(this)
    initView()
    }

    protected open fun bindState() {}

    protected open fun initView() {}

    override fun onResume() {
    super.onResume()
    viewModel.sendLifeEvent(WidgetLifeEvent.ON_SHOW)
    }

    override fun onPause() {
    super.onPause()
    viewModel.sendLifeEvent(WidgetLifeEvent.ON_HIDE)
    }

    /**
    * 分发singleEvent
    */
    protected open fun dispatchSingleEvent(singleEvent: SingleEvent) {}

    /**
    * 绑定singleEvent并处理
    */
    private fun handleSingleEvent(lifecycleOwner: LifecycleOwner){
    viewModel.singleEvent.collectWhenStart(lifecycleOwner){
    "handleSingleEvent->$it".logD()
    when(it) {
    is SingleEvent.HideLoadingDialog -> {
    hideMviLoadingDialog()
    }
    is SingleEvent.ShowLoadingDialog -> {
    showMviLoadingDialog()
    }
    is SingleEvent.ShowContentView -> {
    showMviContentView()
    }
    is SingleEvent.ShowLoadingView -> {
    showMviLoadingView()
    }
    is SingleEvent.ShowToast -> {
    showMviToast(it.msg)
    }
    else -> {
    dispatchSingleEvent(it)
    }
    }
    }
    }


    private fun showMviContentView(){
    "showMviContentView".logD()
    showContentView()
    }

    private fun showMviLoadingView() {
    "showMviLoadingView".logD()
    showLoadingView()
    }

    private fun showMviLoadingDialog(){
    "showMviLoadingDialog".logD()
    showLoadingDialog()
    }

    private fun hideMviLoadingDialog(){
    "hideMviLoadingDialog".logD()
    hideLoadingDialog()
    }

    private fun showMviToast(toast: String){
    "showMviToast:$toast".logD()
    showToast(toast)
    }

    /**
    * state刷新绑定
    */
    protected fun <A> bindPropertyState(kp: KProperty1<S, A>, action: suspend (A) -> Unit){
    viewModel.stateFlow.map {
    data -> kp.get(data)
    }.distinctUntilChanged()
    .collectWhenStart(this) { a ->
    "${this@BaseStateFragment} -> UI收到局部状态 -> ${kp.getter.property}".logD()
    action.invoke(a)
    }
    }


    /**
    * state绑定扩展
    */
    protected fun <A> KProperty1<S, A>.bindState(action: suspend (A) -> Unit) {
    bindPropertyState(this, action)
    }


    /**
    * 返回viewModel的state对象
    */
    protected fun getState(): S{
    return viewModel.state
    }

    /**
    * intent发送扩展
    */
    protected fun <T: I> T.sendToIntent() {
    viewModel.sendIntent(this)
    }

    }

在业务场景中的使用

定义登录接口

  • 这里是Flow + retrofit的风格
    1
    2
    3
    4
    5
    6
    7
    8
    9
    interface LoginApi {

    @POST("/user/login")
    @FormUrlEncoded
    fun realLogin(
    @Field("username") code: String,
    @Field("password") token: String,
    ): Flow<LoginEntity?>
    }

    登录Contract

  • 这里定义UiIntent和UIState
  • UiIntent是一个密封类,每一个Intent都是内部的一个类,内部所有的类均继承UiIntent
  • UiIntent是一个数据类,实现IState接口,每一个State都是内部的一个属性
    1
    2
    3
    4
    5
    6
    7
    8
    9
    interface LoginContract {
    sealed class UiIntent: IIntent {
    class DealLogin(val username: String, val password: String): UiIntent()
    class CheckEnable(val username: String, val password: String): UiIntent()
    }

    data class UIState(val loginEntity: LoginEntity? = null,
    val enable: Boolean? = null): IState
    }

    登录接口数据Bean类

  • 根据后端返回的数据结构封装Bean
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    data class LoginEntity(
    val admin: Boolean? = null,
    val chapterTops: List<Any>? = null,
    val coinCount: Int? = null,
    val collectIds: List<Int>? = null,
    val email: String? = null,
    val icon: String? = null,
    val id: Int? = null,
    val nickname: String? = null,
    val password: String? = null,
    val publicName: String? = null,
    val token: String? = null,
    val type: Int? = null,
    val username: String? = null
    )

    登录ViewModel

  • 扩展ViewModel可接收初始化参数,传入SavedStateHandle
  • 重写stateClass返回LoginContract的UIState的class对象
  • 懒加载初始化FlowApi,关于FlowApi的封装在后面的文章中会详细讨论
  • dispatchIntentOnIO中根据当前的Intent类型去执行对应的表现层逻辑,这里值得注意的是由于是子线程执行,需要在设计时考虑线程安全的问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class LoginViewModel(savedStateHandle: SavedStateHandle): BaseStateViewModel<LoginContract.UiIntent, LoginContract.UIState>() {

override val stateClass: KClass<LoginContract.UIState>
get() = LoginContract.UIState::class

private val loginApi by lazy {
LoginApi::class.java.createFlowApi()
}

override suspend fun dispatchIntentOnIO(intent: LoginContract.UiIntent) {
intent.checkIntent<LoginContract.UiIntent.CheckEnable> {
handleCheckEnable(it.username, it.password)
}.checkIntent<LoginContract.UiIntent.DealLogin> {
handleDealLogin(it.username, it.password)
}
}

private fun handleCheckEnable(username: String, password: String) {
updateState {
copy(enable = username.isNotEmpty() && password.isNotEmpty())
}
}

/**
* 处理登录请求
*/
private suspend fun handleDealLogin(username: String, password: String) {
loginApi.realLogin(username, password).startLoading()
.onSuccess {
updateState { copy(loginEntity = it,) }
}
.onFail {
Log.d("loginApi", it.message.toString())
}
}

}

登录界面

  • 这里就不放xml文件了,主要是展示Fragment和ViewModel之间的交互过程,ViewBinding的封装涉及到了属性代理,在后面的文章中会详细讨论
  • 重写getViewModelFactory方法,返回SavedStateViewModelFactory,可传递参数到ViewModel初始化
  • initView中CheckEnable的逻辑和登录逻辑在ViewModel中实现,通过发送Intent事件到ViewModel走交互逻辑
  • bindState中监听视图状态数据enable获取CheckEnable的结果刷新UI,监听loginEntity获取登录逻辑的数据结果反馈到UI
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    class LoginFragment :
    BaseStateFragment<LoginContract.UiIntent, LoginContract.UIState, LoginViewModel>() {
    override val viewModelClass: KClass<LoginViewModel>
    get() = LoginViewModel::class

    private val viewBinding by viewBinding1(
    FragmentLoginBinding::bind,
    viewProvider = {
    getViewProvider()
    },
    onViewDestroyed = { _: FragmentLoginBinding ->
    // reset view
    })

    override fun getLayout(): Int {
    return R.layout.fragment_login
    }

    override fun getViewModelFactory(): ViewModelProvider.Factory {
    return SavedStateViewModelFactory(
    BaseApplication.get(), this,
    arguments)
    }

    override fun initView() {
    super.initView()
    viewBinding.apply {
    etAccount.doAfterTextChanged {
    LoginContract.UiIntent.CheckEnable(it.toString(),
    viewBinding.passwordToggleView.editText.text.toString()).sendToIntent()
    }
    passwordToggleView.editText.doAfterTextChanged {
    LoginContract.UiIntent.CheckEnable(viewBinding.etAccount.text.toString(),
    it.toString())
    .sendToIntent()
    }
    tvLogin.clickWithTrigger {
    val username = viewBinding.etAccount.text.toString()
    val password = viewBinding.passwordToggleView.editText.text.toString()

    LoginContract.UiIntent.DealLogin(username, password).sendToIntent()
    }
    }
    }


    override fun bindState() {
    super.bindState()
    LoginContract.UIState::loginEntity.bindState {
    it?.let {
    Log.d(TAG, GsonUtils.toJson(it))
    ToastUtils.showShort("登录成功")
    }
    }
    LoginContract.UIState::enable.bindState {
    it?.let {
    viewBinding.tvLogin.isEnabled = it
    }
    }
    }
    }


  • Kotlin是Android开发的趋势,协程是Kotlin必不可少的一部分,而Flow是协程的一部分,是类似RxJava的基于流的一种链式调用的异步响应式编程框架,可以说用kotlin开发Android是绕不开对Flow的学习和使用,关于组件间的通信,事件总线的实现方案有很多,如handler有内存泄漏问题,高耦合且不好维护,本地Broadcast和EventBus无法感知生命周期需要注册和反注册,EventBus还要配置混淆,直接使用interface又不好维护,RxBus学习成本高还依赖RxJava,自从google推出LiveData后封装了LiveDataBus但是要解决粘性事件问题和onCreate/onStop/onDestroy收不到数据问题以及postValue丢值问题,而这些问题在Flow上是不用考虑的
  • 根据大部分业务需要,采用SharedFlow去封装EventBus是目前比较完美的事件总线解决方案,这里记录一下本次封装的详细过程,同时这次封装还解决了大部分事件总线的痛点,临时事件滥用维护难度大,事件名易重复,发送接收数据类型不一致导致数据转换错误,欢迎交流和讨论~

为什么采用SharedFlow

官方推荐用Flow去替代Livedata

从设计上我们可以看出SharedFlow是高配版的LiveData,理论上LiveData能做的它也可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public fun <T> MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T> {
require(replay >= 0) { "replay cannot be negative, but was $replay" }
require(extraBufferCapacity >= 0) { "extraBufferCapacity cannot be negative, but was $extraBufferCapacity" }
require(replay > 0 || extraBufferCapacity > 0 || onBufferOverflow == BufferOverflow.SUSPEND) {
"replay or extraBufferCapacity must be positive with non-default onBufferOverflow strategy $onBufferOverflow"
}
val bufferCapacity0 = replay + extraBufferCapacity
val bufferCapacity = if (bufferCapacity0 < 0) Int.MAX_VALUE else bufferCapacity0 // coerce to MAX_VALUE on overflow
return SharedFlowImpl(replay, bufferCapacity, onBufferOverflow)
}
  • LiveData容量是1,SharedFlow容量支持0到多个
  • LiveData无法应对背压问题,SharedFlow有缓存空间能应对背压问题
  • LiveData固定重播1个数据,SharedFlow支持重播0个到多个数据
  • LiveData只能在主线程订阅,SharedFlow支持在任意线程订阅

    适合大多数业务场景

  • 支持一对多,即一条消息支持多个订阅者
  • 具有时效性,过期的消息没有意义且不应该被延迟发送
    对照SharedFlow本身是热流,支持多个订阅者,默认重播为0,容量为0,不会出现粘性事件,没有订阅直接丢弃

具体实现

定义FlowDataEvent

  • 定义EventBus发送的数据类FlowDataEvent,keyEvent为事件名称,data为发送的任意数据类型
1
class FlowDataEvent(val keyEvent: String, val data: Any): FlowEvent

封装FlowEventBus

  • 定义EventBus的CoroutineScope
  • 定义全局的SharedFlow
  • SharedFlow转冷流与订阅者绑定,LifecycleOwner的扩展用于Fragment中订阅数据,生命周期与Fragment绑定,CoroutineScope的扩展用于ViewModel中订阅数据,生命周期与ViewModel绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
object FlowEventBus {
// flowEventBus对应的scope
private val flowEventBusScope = CoroutineScope(CoroutineName("FlowEventBus"))
// flowEventBus使用全局SharedFlow
private val mutableSharedFlow = MutableSharedFlow<FlowDataEvent>()

private val flowEventBus: Flow<FlowDataEvent> get() = mutableSharedFlow.asSharedFlow()

// SharedFlow绑定scope
init {
mutableSharedFlow.launchIn(flowEventBusScope)
}

/**
* 发送一个事件
*/
fun sendEvent(event: FlowDataEvent) {
flowEventBusScope.launch {
mutableSharedFlow.emit(event)
}
}


/**
* 绑定LifecycleOwner返回FlowDataEvent
*/
fun LifecycleOwner.collectDataEvent(action: suspend (e: FlowDataEvent) -> Unit): Job {
return flowEventBus.collectWhenCreated(this){
action.invoke(it)
}
}


/**
* 绑定Scope返回FlowDataEvent
*/
fun CoroutineScope.collectDataEvent(action: suspend (e: FlowDataEvent) -> Unit): Job {
return launch {
flowEventBus.collect{
action.invoke(it)
}
}
}
}

fun <T> Flow<T>.collectWhenCreated(owner: LifecycleOwner, action: suspend (value: T) -> Unit): Job = owner.lifecycleScope.launch {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
collect{action.invoke(it)}
}
}

对外调用扩展

  • 根据事件名做一个String类型的扩展,LifecycleOwner和CoroutineScope适配Fragment/Activity和ViewModel场景的绑定订阅者,监听事件获取发送的数据
  • 根据需要发送的任意对象,传入事件名称构建一个FlowDataEvent对象并调用FlowEventBus发送
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    /**
    * 绑定事件,生命周期为LifecycleOwner
    */
    fun <T> String.bindFlowEvent(lifecycleOwner: LifecycleOwner, block: (T) -> Unit){
    lifecycleOwner.collectDataEvent {
    if (it.keyEvent == this) {
    val result = it.data as? T
    result?.apply {
    block.invoke(this)
    } ?: "lifecycleOwner bindFlowEvent convert T error".logD()
    }
    }
    }

    /**
    * 绑定事件,生命周期为CoroutineScope
    */
    fun <T> String.bindFlowEvent(coroutineScope: CoroutineScope, block: (T) -> Unit) {
    coroutineScope.collectDataEvent {
    if (it.keyEvent == this) {
    val result = it.data as? T
    result?.apply {
    block.invoke(this)
    } ?: "coroutineScope bindFlowEvent convert T error".logD()
    }
    }
    }


    /**
    * 创建一个当前对象为data的并发送
    */
    fun Any.sendFlowEvent(key: String){
    FlowEventBus.sendEvent(FlowDataEvent(key, this))
    }

    解决痛点

  • 临时事件滥用
    收敛到一个文件,只能根据预先定义好的事件发送和接收
  • 事件名易重复
    采用object事件类的类名作为事件名保证唯一性
  • 发送接收数据类型不一致
    抽象出一个泛型抽象类,传入泛型的具体类型为发送和接收的统一类型,保证发送接收数据类型一致

泛型抽象类

所有FlowEventBus需要发送的事件类都要继承这个抽象类,保证发送和接收数据类型一致

1
2
3
4
5
6
7
8
9
10
11
12
13
 abstract class IEvent<T>  {
open fun sendEvent(t: T){
t?.sendFlowEvent(this.javaClass.name)
}

fun bindEvent(lifecycleOwner: LifecycleOwner, block: (T) -> Unit){
this.javaClass.name.bindFlowEvent(lifecycleOwner, block)
}

fun bindEvent(coroutineScope: CoroutineScope, block: (T) -> Unit){
this.javaClass.name.bindFlowEvent(coroutineScope, block)
}
}

定义FlowEventBus的事件类

所有的事件定义在一个文件中方便维护,避免临时事件,事件名重复问题

1
2
3
object ClickItemEvent: IEvent<BottomSheetDialogHelper.SelectEntity>()

object TestEvent: IEvent<String>()

业务场景中使用

1
2
3
4
5
6
7
8
9
10
11
// 发送 
ClickItemEvent.sendEvent(selectEntity)
TestEvent.sendEvent("Test")

// 接收
ClickItemEvent.bindEvent(this) {
"${it.toJson()}".logD()
}
TestEvent.bindEvent(this) {
showToast("$it")
}


一转眼来到国庆假期,核酸未停疫情还在,假期是个好好充电的日子,休息之余总结之前的工作度过一个充实的假期,但愿疫情快点过去,大家能回归到正常生活吧
上一篇写到了flutter的开发框架封装,其中用到了DioManager这个类,这个类主要是利用flutter框架提供的dio对网络层http请求做一个封装,另外由于业务需要,也封装了WebSocket请求,这里整理了一下具体的封装流程,欢迎一起交流讨论~


http请求封装

封装数据传输Bean基础类

这里以WanAndroid的Api为例

  • data是泛型T,返回的数据内容
  • errorCode是后端返回的int类型的业务状态码
  • errorMsg是错误信息
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    class BaseResult<T> {
    BaseResult({
    this.data,
    this.errorCode,
    this.errorMsg,
    });

    factory BaseResult.fromJson(Map<String, dynamic> json) {
    return BaseResult(
    data: json['data'],
    errorCode: json['errorCode'],
    errorMsg: json['errorMsg']);
    }

    /**
    * 不显示错误toast
    */
    bool isSuccess() {
    return errorCode == 0;
    }

    T? data;
    int? errorCode;
    String? errorMsg;

    Map<String, dynamic> toJson() {
    final map = <String, dynamic>{};
    if (data != null) {
    map['data'] = data;
    }
    map['errorCode'] = errorCode;
    map['errorMsg'] = errorMsg;
    return map;
    }
    }

    单例模式

    构造函数私有化,懒汉式构造单例
    1
    2
    3
    4
    5
    6
    7
    8
    static DioManager? instance;

    static DioManager? getInstance() {
    instance ??= DioManager._internal();
    return instance;
    }

    DioManager._internal();

    声明baseUrl和全局Header

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    static String getAppUrl() {
    return "https://www.wanandroid.com";
    }

    static Map<String, dynamic> baseHeaders = {
    "X-SYSTEM": "Android",
    "LANG": "zh_CN",
    "x-app-token": "",
    "X-VERSION": ""
    };

    初始化DioManager

    dio对象全局唯一,DioManager的构造函数中需要对options进行相关配置,全局header和baseUrl以及连接超时,接收超时,发送超时,添加日志拦截器,设置数据的返回格式为Json
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    Dio dio = Dio();

    DioManager() {
    dio.options.headers = baseHeaders;
    dio.options.baseUrl = getAppUrl();
    dio.options.connectTimeout = 15000;
    dio.options.receiveTimeout = 15000;
    dio.options.sendTimeout = 30000;
    // 是否开启请求日志
    if (Global.isDebug) {
    dio.interceptors.addAll([LogInterceptor()]);
    }
    dio.options.responseType = ResponseType.json;
    }

    封装get和post请求

  • post请求有表单和body两种方式,isForm为true时为表单请求默认为false
  • _request方法中统一处理,调用dio的request方法传path,data,queryParameters和options发起请求,根据post和get请求配置options的method,根据post表单和body两种请求方式配置contentType,如果是get请求携带的参数传queryParameters,post请求携带的参数传data
  • dio的request需要try-catch捕获异常,如果捕获到异常且needToast为true时通过toast展示异常code
  • 获取到data数据后,如果是string则直接反序列化,否则调用Bean基础类BaseResult的fromJson反序列化,反序列化操作需要try-catch捕获异常,如果成功直接返回结果,否则返回序列化失败,如果当前是错误状态码且needToast为true时通过toast展示toast
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    Future<BaseResult> getRequest(String url, {Map<String, dynamic>? params}) {
    return _request(
    url,
    queryParameters: params,
    options: Options(method: "GET"),
    );
    }

    Future<BaseResult> postRequest(String url,
    {dynamic params, bool isForm = false}) {
    final contentType =
    isForm ? Headers.formUrlEncodedContentType : Headers.jsonContentType;
    final options = Options(
    method: "POST",
    extra: {"dio_content_type": contentType},
    contentType: contentType);
    return _request(url, data: params, options: options);
    }

    Future<BaseResult> _request(String path,
    {data,
    Map<String, dynamic>? queryParameters,
    Options? options,
    bool needToast = true}) async {
    final Response response;
    try {
    response = await dio.request(path,
    data: data, queryParameters: queryParameters, options: options);
    } on DioError catch (error, stackTrace) {
    var checkedError = _checkError(error);
    final statusCode =
    checkedError.response?.statusCode ?? ResultCode.NO_NETWORK;
    if (needToast) {
    showToast("network_error(${statusCode.toString()})");
    }
    if (Global.isDebug) Future.error(checkedError, stackTrace);
    return BaseResult(errorCode: statusCode, errorMsg: error.message);
    }

    try {
    //防止解析失败
    var data = response.data;
    if (data is String) {
    data = json.decode(data);
    }
    var baseResult = BaseResult.fromJson(data);
    if (!baseResult.isSuccess() && needToast) {
    showToast(baseResult.errorMsg ?? "");
    }
    return baseResult;
    } catch (ex, stackTrace) {
    if (Global.isDebug) Future.error(ex, stackTrace);
    return BaseResult(
    errorCode: ResultCode.JSON_ERROR, errorMsg: ex.toString());
    }
    }

    错误码统一处理

    如果返回的DioError的response为空或statusCode为空统一处理为没有网络,其余的错误类型转换为自定义的错误码便于混合开发时统计
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    DioError _checkError(DioError error) {
    // 如果response为空构造没有网络的response
    // 如果statusCode为空直接赋值为没有网络
    /// 统一认为是没有网络
    Response errorResponse = error.response ??
    Response(
    statusCode: ResultCode.NO_NETWORK,
    requestOptions: error.requestOptions);

    errorResponse.statusCode ??= ResultCode.NO_NETWORK;

    /// 自定义请求超时错误码
    if (error.type == DioErrorType.connectTimeout) {
    errorResponse.statusCode = ResultCode.CONNECT_TIMEOUT;
    } else if (error.type == DioErrorType.receiveTimeout) {
    errorResponse.statusCode = ResultCode.RECEIVE_TIMEOUT;
    } else if (error.type == DioErrorType.sendTimeout) {
    errorResponse.statusCode = ResultCode.SEND_TIMEOUT;
    }
    error.response = errorResponse;
    return error;
    }

    class ResultCode {
    /// 业务错误
    static const BIZ_ERROR = -111;

    /// json解析异常
    static const JSON_ERROR = -100;

    /// 没有网络
    static const NO_NETWORK = -101;

    ///连接超时
    static const CONNECT_TIMEOUT = -102;

    /// 接收超时
    static const RECEIVE_TIMEOUT = -103;

    ///写数据超时
    static const SEND_TIMEOUT = -104;
    }
    最后附上DioManager完整的源码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    class DioManager {
    static DioManager? instance;

    factory DioManager() {
    instance ??= DioManager();
    return instance!;
    }

    static String getAppUrl() {
    return "https://www.wanandroid.com";
    }

    static Map<String, dynamic> baseHeaders = {
    "X-SYSTEM": "Android",
    "LANG": "zh_CN",
    "x-app-token": "",
    "X-VERSION": ""
    };

    Dio dio = Dio();

    DioManager._internal() {
    dio.options.headers = baseHeaders;
    dio.options.baseUrl = getAppUrl();
    dio.options.connectTimeout = 15000;
    dio.options.receiveTimeout = 15000;
    dio.options.sendTimeout = 30000;
    // 是否开启请求日志
    if (Global.isDebug) {
    dio.interceptors.addAll([LogInterceptor()]);
    }
    dio.options.responseType = ResponseType.json;
    }

    Future<BaseResult> getRequest(String url, {Map<String, dynamic>? params}) {
    return _request(
    url,
    queryParameters: params,
    options: Options(method: "GET"),
    );
    }

    Future<BaseResult> postRequest(String url,
    {dynamic params, bool isForm = false}) {
    final contentType =
    isForm ? Headers.formUrlEncodedContentType : Headers.jsonContentType;
    final options = Options(
    method: "POST",
    extra: {"dio_content_type": contentType},
    contentType: contentType);
    return _request(url, data: params, options: options);
    }

    Future<BaseResult> _request(String path,
    {data,
    Map<String, dynamic>? queryParameters,
    Options? options,
    bool needToast = true}) async {
    final Response response;
    try {
    response = await dio.request(path,
    data: data, queryParameters: queryParameters, options: options);
    } on DioError catch (error, stackTrace) {
    var checkedError = _checkError(error);
    final statusCode =
    checkedError.response?.statusCode ?? ResultCode.NO_NETWORK;
    if (needToast) {
    showToast("network_error(${statusCode.toString()})");
    }
    if (Global.isDebug) Future.error(checkedError, stackTrace);
    return BaseResult(errorCode: statusCode, errorMsg: error.message);
    }

    try {
    //防止解析失败
    var data = response.data;
    if (data is String) {
    data = json.decode(data);
    }
    var baseResult = BaseResult.fromJson(data);
    if (!baseResult.isNotShowNetErrorToast() && needToast) {
    showToast(baseResult.errorMsg ?? "");
    }
    return baseResult;
    } catch (ex, stackTrace) {
    if (Global.isDebug) Future.error(ex, stackTrace);
    return BaseResult(
    errorCode: ResultCode.JSON_ERROR, errorMsg: ex.toString());
    }
    }

    DioError _checkError(DioError error) {
    /// 统一认为是没有网络
    // 如果response为空构造没有网络的response
    // 如果statusCode为空直接赋值为没有网络
    Response errorResponse = error.response ??
    Response(
    statusCode: ResultCode.NO_NETWORK,
    requestOptions: error.requestOptions);

    errorResponse.statusCode ??= ResultCode.NO_NETWORK;

    /// 自定义请求超时错误码
    if (error.type == DioErrorType.connectTimeout) {
    errorResponse.statusCode = ResultCode.CONNECT_TIMEOUT;
    } else if (error.type == DioErrorType.receiveTimeout) {
    errorResponse.statusCode = ResultCode.RECEIVE_TIMEOUT;
    } else if (error.type == DioErrorType.sendTimeout) {
    errorResponse.statusCode = ResultCode.SEND_TIMEOUT;
    }
    error.response = errorResponse;
    return error;
    }
    }

    /// 跟宿主定义的http网络错误码保持一致
    class ResultCode {
    /// 业务错误
    static const BIZ_ERROR = -111;

    /// json解析异常
    static const JSON_ERROR = -100;

    /// 没有网络
    static const NO_NETWORK = -101;

    ///连接超时
    static const CONNECT_TIMEOUT = -102;

    ///It occurs when receiving timeout.
    static const RECEIVE_TIMEOUT = -103;

    ///写数据超时
    static const SEND_TIMEOUT = -104;
    }

    WebSocket请求封装

    封装数据传输Bean基础类

    根据服务端返回的内容封装一个bean类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class WsBaseBean {
    Decimal? changeRate;
    Decimal? lastTradedPrice;
    String? symbolCode;
    Decimal? volValue;

    WsBaseBean(
    {this.changeRate, this.lastTradedPrice, this.symbolCode, this.volValue});

    factory WsBaseBean.fromJson(Map<String, dynamic> json) {
    return WsBaseBean(
    changeRate: Decimal.tryParse("${json["changeRate"] ?? 0.0}"),
    lastTradedPrice: Decimal.tryParse("${json["lastTradedPrice"] ?? 0.0}"),
    symbolCode: json["symbolCode"],
    volValue: Decimal.tryParse("${json["volValue"] ?? 0.0}"));
    }
    }

    单例模式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /// 单例对象
    static WebSocketUtility? _socket;

    /// 内部构造方法,可避免外部暴露构造函数,进行实例化
    WebSocketUtility._internal();

    /// 获取单例内部方法
    factory WebSocketUtility() {
    // 只能有一个实例
    _socket ??= WebSocketUtility._internal();
    return _socket!;
    }

    声明Websocket地址和连接状态

    1
    2
    3
    4
    5
    6
    7
    const String _SOCKET_URL = 'ws://192.168.3.123:8181/test';

    enum SocketStatus {
    socketStatusConnected, // 已连接
    socketStatusFailed, // 失败
    socketStatusClosed, // 连接关闭
    }

    声明变量

  • 定义回调,接连错误,接收消息,开始连接
  • 定义IOWebSocketChannel实现WebSocket通信
  • 定义心跳定时器和心跳间隔,每隔固定时长发送心跳
  • 定义重连定时器和当前重连次数和最大重连次数,未达到最大重连次数时每隔固定时长重新连接
  • 定义当前socket的连接状态
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    IOWebSocketChannel? _webSocket; // WebSocket
    SocketStatus? _socketStatus; // socket状态
    Timer? _heartBeat; // 心跳定时器
    final int _heartTimes = 3000; // 心跳间隔(毫秒)
    final int _reconnectMaxCount = 60; // 重连次数,默认60次
    int _reconnectTimes = 0; // 重连计数器
    Timer? _reconnectTimer; // 重连定时器
    late Function onError; // 连接错误回调
    late Function onOpen; // 开始连接回调
    late Function onMessage; // 接收消息回调

    开始连接

  • 初始化IOWebSocketChannel开始连接
  • 设置_socketStatus连接状态
  • 重置重连计数和定时器
  • 调用onOpen回调
  • 接收消息回调处理,收到消息调onMessage回调,连接错误调onError回调,连接推出时尝试重连
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    /// 开启WebSocket连接
    void openSocket() {
    closeSocket();
    _webSocket = IOWebSocketChannel.connect(_SOCKET_URL);
    print('WebSocket连接成功: $_SOCKET_URL');
    // 连接成功,返回WebSocket实例
    _socketStatus = SocketStatus.socketStatusConnected;
    // 连接成功,重置重连计数器
    _reconnectTimes = 0;
    if (_reconnectTimer != null) {
    _reconnectTimer?.cancel();
    _reconnectTimer = null;
    }
    onOpen();
    // 接收消息
    _webSocket?.stream.listen((data) => onMessage(data), onError: (e) {
    WebSocketChannelException ex = e;
    _socketStatus = SocketStatus.socketStatusFailed;
    onError(ex.message);
    closeSocket();
    }, onDone: () {
    print('closed');
    reconnect();
    });
    }

    销毁心跳

    取消心跳定时器
    1
    2
    3
    4
    5
    6
    void destroyHeartBeat() {
    if (_heartBeat != null) {
    _heartBeat?.cancel();
    _heartBeat = null;
    }
    }

    初始化并开启心跳

    如果心跳定时器已经初始化则销毁,重新初始化并开启心跳发送任务
    1
    2
    3
    4
    5
    6
    void initHeartBeat() {
    destroyHeartBeat();
    _heartBeat = Timer.periodic(Duration(milliseconds: _heartTimes), (timer) {
    sendMessage('{"module": "HEART_CHECK", "message": "请求心跳"}');
    });
    }

    发送WebSocket消息

    如果当前的连接状态是已连接则发送消息,否则打印当前状态
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /// 发送WebSocket消息
    void sendMessage(message) {
    if (_webSocket != null) {
    switch (_socketStatus) {
    case SocketStatus.socketStatusConnected:
    print('发送中:' + message);
    _webSocket?.sink.add(message);
    break;
    case SocketStatus.socketStatusClosed:
    print('连接已关闭');
    break;
    case SocketStatus.socketStatusFailed:
    print('发送失败');
    break;
    default:
    break;
    }
    }
    }

    重连机制

    如果当前未达到最大连接次数则重连次数+1,开启重连定时器,执行WebSocket连接,否则如果重连定时器不为空则销毁
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /// 重连机制
    void reconnect() {
    if (_reconnectTimes < _reconnectMaxCount) {
    _reconnectTimes++;
    _reconnectTimer =
    Timer.periodic(Duration(milliseconds: _heartTimes), (timer) {
    openSocket();
    });
    } else {
    if (_reconnectTimer != null) {
    print('重连次数超过最大次数');
    _reconnectTimer?.cancel();
    _reconnectTimer = null;
    }
    return;
    }
    }
    最后附上WebSocketUtility完整的源码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    /// WebSocket地址
    const String _SOCKET_URL = 'ws://192.168.3.123:8181/test';

    /// WebSocket状态
    enum SocketStatus {
    socketStatusConnected, // 已连接
    socketStatusFailed, // 失败
    socketStatusClosed, // 连接关闭
    }

    class WebSocketUtility {
    /// 单例对象
    static WebSocketUtility? _socket;

    /// 内部构造方法,可避免外部暴露构造函数,进行实例化
    WebSocketUtility._internal();

    /// 获取单例内部方法
    factory WebSocketUtility() {
    // 只能有一个实例
    _socket ??= WebSocketUtility._internal();
    return _socket!;
    }

    IOWebSocketChannel? _webSocket; // WebSocket
    SocketStatus? _socketStatus; // socket状态
    Timer? _heartBeat; // 心跳定时器
    final int _heartTimes = 3000; // 心跳间隔(毫秒)
    final int _reconnectMaxCount = 60; // 重连次数,默认60次
    int _reconnectTimes = 0; // 重连计数器
    Timer? _reconnectTimer; // 重连定时器
    late Function onError; // 连接错误回调
    late Function onOpen; // 连接开启回调
    late Function onMessage; // 接收消息回调

    /// 初始化WebSocket
    void initWebSocket(
    {required Function onOpen,
    required Function onMessage,
    required Function onError}) {
    this.onOpen = onOpen;
    this.onMessage = onMessage;
    this.onError = onError;
    openSocket();
    }

    /// 开启WebSocket连接
    void openSocket() {
    closeSocket();
    _webSocket = IOWebSocketChannel.connect(_SOCKET_URL);
    print('WebSocket连接成功: $_SOCKET_URL');
    // 连接成功,返回WebSocket实例
    _socketStatus = SocketStatus.socketStatusConnected;
    // 连接成功,重置重连计数器
    _reconnectTimes = 0;
    if (_reconnectTimer != null) {
    _reconnectTimer?.cancel();
    _reconnectTimer = null;
    }
    onOpen();
    // 接收消息
    _webSocket?.stream.listen((data) => onMessage(data), onError: (e) {
    WebSocketChannelException ex = e;
    _socketStatus = SocketStatus.socketStatusFailed;
    onError(ex.message);
    closeSocket();
    }, onDone: () {
    print('closed');
    reconnect();
    });
    }

    /// 初始化心跳
    void initHeartBeat() {
    destroyHeartBeat();
    _heartBeat = Timer.periodic(Duration(milliseconds: _heartTimes), (timer) {
    sendMessage('{"module": "HEART_CHECK", "message": "请求心跳"}');
    });
    }

    /// 销毁心跳
    void destroyHeartBeat() {
    if (_heartBeat != null) {
    _heartBeat?.cancel();
    _heartBeat = null;
    }
    }

    /// 关闭WebSocket
    void closeSocket() {
    if (_webSocket != null) {
    print('WebSocket连接关闭');
    _webSocket?.sink.close();
    destroyHeartBeat();
    _socketStatus = SocketStatus.socketStatusClosed;
    }
    }

    /// 发送WebSocket消息
    void sendMessage(message) {
    if (_webSocket != null) {
    switch (_socketStatus) {
    case SocketStatus.socketStatusConnected:
    print('发送中:' + message);
    _webSocket?.sink.add(message);
    break;
    case SocketStatus.socketStatusClosed:
    print('连接已关闭');
    break;
    case SocketStatus.socketStatusFailed:
    print('发送失败');
    break;
    default:
    break;
    }
    }
    }

    /// 重连机制
    void reconnect() {
    if (_reconnectTimes < _reconnectMaxCount) {
    _reconnectTimes++;
    _reconnectTimer =
    Timer.periodic(Duration(milliseconds: _heartTimes), (timer) {
    openSocket();
    });
    } else {
    if (_reconnectTimer != null) {
    print('重连次数超过最大次数');
    _reconnectTimer?.cancel();
    _reconnectTimer = null;
    }
    return;
    }
    }
    }

    在Widget中使用

    在initState中初始化WebSocket并在连接后开启心跳发送任务与服务端保持通信连接,在消息接收中反序列化实时刷新页面数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    class SocketTestWidget extends StatefulWidget {
    const SocketTestWidget({Key? key}) : super(key: key);

    @override
    State<SocketTestWidget> createState() => _SocketTestWidgetState();
    }

    class _SocketTestWidgetState extends State<SocketTestWidget> {
    dynamic data;

    @override
    void initState() {
    super.initState();
    WebSocketUtility().initWebSocket(onOpen: () {
    WebSocketUtility().initHeartBeat();
    }, onMessage: (data) {
    WsBaseBean wsBaseBean = WsBaseBean.fromJson(jsonDecode(data));
    setState(() {
    this.data = wsBaseBean.changeRate;
    });
    }, onError: (e) {
    print("WebSocketUtility" + e);
    });
    }

    @override
    void dispose() {
    super.dispose();
    }

    @override
    Widget build(BuildContext context) {
    return Scaffold(
    appBar: AppBar(
    title: const Text("SocketTest"),
    ),
    body: Center(
    child: ListView(
    children: [Text('收到数据: ${data}')],
    ),
    ),
    );
    }
    }
    看下flutter客户端的控制台输出效果
    效果图

最后我们可以用python写个简单的服务端脚本测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import asyncio
import websockets
import time
import json
import random
websocket_users = set()

# 接收客户端消息并处理
async def recv_user_msg(websocket):
while True:
recv_text = await websocket.recv()
print("recv_text:", recv_text)
changeRate = random.randint(2000, 3000) + random.random()
lastTradedPrice = random.randint(1000, 2000) + random.random()
volValue = random.randint(4000, 5000) + random.random()
recv_text = {'changeRate' : changeRate, 'lastTradedPrice' : lastTradedPrice, 'symbolCode' : "BTC-USDT", 'volValue' : volValue}
data = json.dumps(recv_text)
print(data)
response_text = data
print("response_text:", f"Server return: {data}")
await websocket.send(response_text)
time.sleep(5)

# 服务器端主逻辑
async def run(websocket, path):
while True:
try:
await recv_user_msg(websocket)
except websockets.ConnectionClosed:
print("ConnectionClosed...", path) # 链接断开
print("websocket_users old:", websocket_users)
websocket_users.remove(websocket)
print("websocket_users new:", websocket_users)
break
except websockets.InvalidState:
print("InvalidState...") # 无效状态
break
except Exception as e:
print("Exception:", e)


if __name__ == '__main__':
print("192.168.3.123:8181 websocket...")
asyncio.get_event_loop().run_until_complete(websockets.serve(run, "192.168.3.123", 8181))
asyncio.get_event_loop().run_forever()

看下服务端的控制台输出效果
效果图


  • 今年的互联网寒冬真是冰冷刺骨,高温+限电+地震+疫情让所有人都感觉到2022年活下来就好,既然疫情下的经济衰退无法避免,那就只能多准备点保暖的装备过冬了,作为菜鸟的我焦虑是无法避免的而学习可以平复心中的不安,大环境无法改变只能抓紧时间充电了,期待破局的那一天。
  • 移动端开发趋近于饱和已经很久了,传统的开发方式需要ios和Android的两端的人力,为了达到双端一致的效果少不了沟通和UI交互校对的成本,如果项目以UI交互为主利用跨平台开发是有优势的,一套代码打造各端UI一致的体验,性能接近原生,提升人效受各大企业青睐,可能这是客户端最终的归宿吧,前有facebook的React后有google的flutter,连compose都有跨平台发展的迹象,随着flutter3.0的发布已经稳定支持所有终端,作为Android开发者面对这种强大的开发框架必须要赶紧学习起来,保持新技术的学习热情,顺应互联网时代的快速发展。
  • 公司的项目中已经用上了flutter,对于二级页面采用混合式的开发方式,集成flutter到原生的项目当中,目前性能和稳定性表现都是非常不错的,由于hot reload开发效率也提升了不少,开发了一段时间后,我利用Android开发中的MVI架构思想和GetX状态管理封装了flutter的开发架构,目前集成到项目中在线上稳定运行,欢迎交流和讨论~

架构图

效果图

  • 先放上一张整体的架构图~
  • 借鉴Android的MVI架构思想,整理一下大概思路:
  1. ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据
  2. 界面会向 ViewModel 发送用户事件通知
  3. ViewModel 会处理用户操作并更新状态
  4. 更新后的状态将反馈给界面呈现
  5. ViewModel的数据源是两种,本地数据源和服务端数据源,本地数据源来自于文件和数据库,服务端数据源是web服务端接口提供的数据

Repository

LocalDataSource本地存储

引入shared_preferences到pubspec.yaml

工具类封装

对shared_preferences进行封装,调用shared_preferences

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Future<bool> saveString2Sp(String key, String value) async {
final prefs = await SharedPreferences.getInstance();
return prefs.setString(key, value);
}

Future<String?> getStringFromSp(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(key);
}

Future<bool> saveBool2Sp(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
return prefs.setBool(key, value);
}

Future<bool?> getBoolFromSp(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(key);
}

Future<bool> removeSp(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.remove(key);
}

本地存储基类

调用封装的工具类实现本地数据的操作

  • 本地数据缓存
  • 本地数据获取
  • 删除本地数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    abstract class BaseLocalDataSource {
    Future<bool> saveCache(String cacheData, String key) async {
    return saveString2Sp(key, cacheData);
    }

    Future<String?> readCache(String key) async {
    return getStringFromSp(key);
    }

    Future<bool?> deleteCache(String key) async {
    return removeSp(key);
    }
    }

    网络请求基类

    引入dio到pubspec.yaml,初始化网络请求DioManager对象供实现类使用,封装了网络请求的Dio API
    1
    2
    3
    abstract class BaseRemoteDataSource {
    final DioManager dioManager = DioManager.getInstance()!;
    }

    Repository基类

    暴露本地数据操作接口
  • 本地数据缓存
  • 本地数据获取
  • 删除本地数据
    1
    2
    3
    4
    5
    abstract class BaseRepository {
    Future<bool> saveCache(String cacheData, String key);
    Future<String?> readCache(String key);
    Future<bool?> deleteCache(String key);
    }

    Repository基础实现类

  • 需外部传入本地数据,网络请求操作对象
  • 方便外部自定义BaseLocalDataSource,替换shared_preferences,实现本地数据操作方法
  • 方便外部自定义BaseRemoteDataSource, 定义网络请求的相关接口
  • 网络请求的相关接口在外部Repository实现类中实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    abstract class BaseRepositoryImp<Local extends BaseLocalDataSource,
    Remote extends BaseRemoteDataSource> implements BaseRepository {
    final Local? localDataSource;
    final Remote remoteDataSource;

    BaseRepositoryImp({this.localDataSource, required this.remoteDataSource});

    @override
    Future<bool> saveCache(String cacheData, String key) async {
    var isSuccess = await localDataSource?.saveCache(cacheData, key);
    return isSuccess ?? false;
    }

    @override
    Future<String?> readCache(String key) async {
    return localDataSource?.readCache(key);
    }

    @override
    Future<bool?> deleteCache(String key) async {
    var isSuccess = await localDataSource?.deleteCache(key);
    return isSuccess ?? false;
    }
    }

    Binding基类

    注入repository实现类
    1
    2
    3
    4
    // 页面binding封装,注入repository
    abstract class BaseBinding<R extends BaseRepository> extends Bindings {
    R provideRepository();
    }

    封装页面基类

    继承StatelessWidget,暴露viewModel的get接口,重写build方法
    1
    2
    3
    4
    5
    6
    7
    abstract class BaseView<T extends BaseViewModel> extends StatelessWidget {
    const BaseView({Key? key}) : super(key: key);
    T get viewModel => GetInstance().find<T>();

    @override
    Widget build(BuildContext context);
    }

    封装页面基类ViewModel

  1. 引入GetX到pubspec.yaml
  2. FullLifeCycleController的生命周期
  • onInit Controller初始化
  • onReady 处理异步事件,网络请求
  • onResumed 应用可见且页面回到前台
  • onInactive 应用在前台但页面不可见
  • onPaused 应用不可见且页面到后台
  • onDetach 页面视图销毁
  • onClose 关闭流对象,动画,释放内存,数据持久化
  1. 继承FullLifeCycleController混入FullLifeCycleMixin重写生命周期方法,传入Repository的实现类定义Repository对象的具体类型,初始化数据埋点收集对象TrackPageViewHelper,通过桥调用native SDK中的API实现的,传入Repository对象,在生命周期做数据埋点,onInit和onResumed调用数据埋点的onPageShow,onInactive和onClose调用数据埋点的onPageHide
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    abstract class BaseViewModel<Repository extends BaseRepository?>
    extends FullLifeCycleController with FullLifeCycleMixin {
    final Repository? repository;

    BaseViewModel({this.repository});

    TrackPageViewHelper? trackPageViewHelper;

    String pageId() => '';

    @override
    @mustCallSuper
    void onInit() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper = TrackPageViewHelper(pageId());
    trackPageViewHelper?.onPageShow();
    }

    /// viewModel的初始化工作,例如一些成员属性的初始化
    super.onInit();
    }

    @override
    void onReady() {
    /// 处理异步事件,比如网络请求
    super.onReady();
    }

    @override
    @mustCallSuper
    void onClose() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper?.onPageHide();
    }
    super.onClose();
    }

    @override
    @mustCallSuper
    void onResumed() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper?.onPageShow();
    }
    }

    @override
    @mustCallSuper
    void onInactive() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper?.onPageHide();
    }
    }

    @override
    void onPaused() {}

    @override
    void onDetached() {}
    }

    通用页面基类

    根据当前封装的页面基类和ViewModel基类封装普通/列表/下拉刷新列表页,分别封装它们对应的UIState/Page/ViewModel

    普通页基类

    封装UIState

    UIState只定义与UI刷新相关的变量,结合GetX的Obx机制刷新使用obs变量,普通页的UIState封装内容View的显示状态,根据当前的几个状态加载中/空页面/错误页来展示页面当前View
    1
    2
    3
    4
    5
    6
    7
    // 只定义UI显示所需要的state,其他和界面显示无关的state,请放在ViewModel
    // 普通页面UIState
    abstract class BasePageUIState {
    bool isLoading = true;
    bool isEmpty = false;
    bool isError = false;
    }

    封装ViewModel

    传入UIState的实例化对象和Repository的实例化对象,继承自前面封装的基类ViewModel,暴露状态传入的接口并利用GetX的GetBuilder刷新状态视图
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    abstract class BasePageViewModel<UIState extends BasePageUIState,
    Repository extends BaseRepository?> extends BaseViewModel<Repository?> {
    BasePageViewModel({Repository? repository, required this.uiState})
    : super(repository: repository);

    final UIState uiState;

    void activeStatus(
    {bool isLoading = false, bool isEmpty = false, bool isError = false}) {
    uiState.isLoading = isLoading;
    uiState.isEmpty = isEmpty;
    uiState.isError = isError;
    update(['status']);
    }
    }

    封装普通页面

  • 定义页面背景色,appBar背景色
  • 重写build方法,设置页面背景色和appBar背景色
  • 标题居中,设置左侧返回按钮,右侧操作按钮
  • 暴露标题设置,左侧/右侧按钮设置,空界面,网络错误界面,重试,点击返回,内容界面绘制接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    abstract class BasePage<T extends BasePageViewModel> extends BaseView<T> {
    const BasePage({Key? key}) : super(key: key);

    Color get backgroundColor => ColorConfig.background;

    Color get appBarBackgroundColor => ColorConfig.background;

    @override
    Widget build(BuildContext context) {
    return Scaffold(
    backgroundColor: backgroundColor,
    resizeToAvoidBottomInset: false,
    appBar: appbar(context),
    body: body(context),
    );
    }

    @protected
    PreferredSizeWidget? appbar(BuildContext buildContext) {
    return AppBar(
    brightness: Global.isDark! ? Brightness.dark : Brightness.light,
    backgroundColor: appBarBackgroundColor,
    elevation: 0,
    centerTitle: true,
    leading: leading(buildContext),
    title: title(),
    actions: actions(),
    );
    }

    /// 返回按钮
    @protected
    Widget? leading(BuildContext context) {
    return IconButton(
    splashColor: Colors.transparent,
    highlightColor: Colors.transparent,
    icon: Image.asset(
    'resource/images/icon_back.png',
    width: 24,
    height: 24,
    color: ColorConfig.emphasis,
    ),
    onPressed: () {
    onBackClick(context);
    },
    );
    }

    /// 标题字符串
    @protected
    String titleString() => '';

    /// 标题
    @protected
    Widget? title() {
    return Container(
    padding: EdgeInsetsDirectional.only(bottom: 5),
    child: Text(
    titleString(),
    overflow: TextOverflow.ellipsis,
    style: TextStyle(
    color: ColorConfig.emphasis,
    fontSize: 18,
    ),
    textAlign: TextAlign.center,
    ),
    );
    }

    List<Widget>? actions() {
    return null;
    }

    Widget body(BuildContext context) {
    return GetBuilder<T>(
    id: 'status',
    builder: (viewModel) {
    if (viewModel.uiState.isLoading) {
    return loading();
    } else if (viewModel.uiState.isEmpty) {
    return empty();
    } else if (viewModel.uiState.isError) {
    return networkError();
    } else {
    return content(context);
    }
    });
    }

    /// 子类实现该方法,创建自己的UI
    @protected
    Widget content(BuildContext context);

    @protected
    Widget loading() {
    return const PageLoading();
    }

    @protected
    Widget empty() {
    return const Padding(
    padding: EdgeInsetsDirectional.only(
    top: 152,
    ),
    child: PageEmpty(),
    );
    }

    @protected
    Widget networkError() {
    return PageNetworkError(
    onRetry: onRetry,
    );
    }

    /// 点击重试时回调
    @protected
    void onRetry() {}

    @protected
    void onBackClick(BuildContext context) {
    if (Navigator.canPop(context)) {
    Navigator.pop(context);
    } else {
    Global.bridge.pop();
    }
    }
    }

    列表页基类

    封装列表UIState

    继承普通页的UIState增加dataList变量
    1
    2
    3
    4
    5
    6
    /**
    * 普通列表UIState
    */
    class BaseListPageUIState<T> extends BasePageUIState {
    final List<T> dataList = [];
    }

    封装列表ViewModel

    传入列表UIState实现类,列表Repository实现类,重写onReady方法调数据刷新接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    abstract class BaseListPageViewModel<UIState extends BaseListPageUIState,
    Repository extends BaseRepository>
    extends BasePageViewModel<UIState, Repository> {
    BaseListPageViewModel(
    {required Repository repository, required UIState uiState})
    : super(repository: repository, uiState: uiState);

    @override
    void onReady() {
    refreshData(isFirstLoading: true);
    super.onReady();
    }

    Future<void> refreshData({bool isFirstLoading = false});
    }

    封装列表页

    继承普通页面基类,传入列表ViewModel,重写content方法返回带下拉刷新的listview,暴露itemExtent接口设置固定的高度重写可提升性能,设置itemCount为viewModel内置UIState中的dataList的数据长度,暴露listview的item接口给外部定义视图
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    abstract class BaseListPage<T extends BaseListPageViewModel>
    extends BasePage<T> {
    const BaseListPage({Key? key}) : super(key: key);

    @override
    Widget content(BuildContext context) {
    return GetBuilder<T>(
    id: 'listview',
    builder: (viewModel) {
    return EasyRefresh(
    onRefresh: () async {
    await viewModel.refreshData();
    },
    child: ListView.builder(
    scrollDirection: Axis.vertical,
    itemExtent: itemExtent(),
    itemCount: itemCount(),
    shrinkWrap: true,
    itemBuilder: (BuildContext context, int index) {
    return item(context, index);
    },
    ),
    );
    });
    }

    /// 已知item固定高度时设置itemExtent值,提升列表滚动性能
    @protected
    double? itemExtent() => null;

    @protected
    int? itemCount() => viewModel.uiState.dataList.length;

    Widget item(BuildContext context, int index);
    }

    下拉刷新列表页基类

    封装UIState

    继承列表UIState增加isEmptyList变量用于控制是否展示空列表视图
    1
    2
    3
    class BaseLoadMoreListPageUIState<T> extends BaseListPageUIState<T> {
    bool isEmptyList = false;
    }

    封装ViewModel

    继承列表ViewModel传入列表UIState实现类,列表Repository实现类,定义EasyRefreshController对象并在onInit中初始化,定义当前页数和是否有下一页变量支持分页加载,暴露加载下一页数据和刷新列表的接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    abstract class BaseLoadMoreListViewModel<
    UIState extends BaseLoadMoreListPageUIState,
    Repository extends BaseRepository>
    extends BaseListPageViewModel<UIState, Repository> {
    late final EasyRefreshController controller;
    @protected
    int currentPage = 1;
    @protected
    bool hasNext = false;

    BaseLoadMoreListViewModel(
    {required Repository repository, required UIState uiState})
    : super(repository: repository, uiState: uiState);

    @override
    void onInit() {
    controller = EasyRefreshController();
    super.onInit();
    }

    // 加载下一页数据时,后端某些分页api采用偏移量计算下一页数据;某些是currentPage自增;
    Future<void> loadNextPageData();

    @override
    void onClose() {
    controller.dispose();
    super.onClose();
    }

    void updatePageList() {
    update(['listView']);
    }
    }

    封装加载更多列表页

    继承列表页基类,传入加载更多列表的ViewModel,重写content方法,利用GetX的GetBuilder设置列表的id,设置列表的下拉刷新和加载更多回调,根据UIState的isEmptyList设置通用空白页面的显示,设置列表的通用底部视图,如果列表不为空加载下一页否则不加载数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    abstract class BaseLoadMoreListPage<T extends BaseLoadMoreListViewModel>
    extends BaseListPage<T> {
    const BaseLoadMoreListPage({Key? key}) : super(key: key);

    @override
    Widget content(BuildContext context) {
    return GetBuilder<T>(
    id: 'listView',
    builder: (viewModel) {
    return createContentWidget(viewModel);
    },
    );
    }

    @protected
    Widget createContentWidget(viewModel) {
    return EasyRefresh(
    enableControlFinishLoad: false,
    enableControlFinishRefresh: false,
    taskIndependence: true,
    topBouncing: false,
    behavior: null,
    bottomBouncing: false,
    footer: PageFooter(),
    controller: viewModel.controller,
    emptyWidget: viewModel.uiState.isEmptyList ? empty() : null,
    onRefresh: () async {
    await viewModel.refreshData();
    },
    onLoad:
    viewModel.uiState.isEmptyList || viewModel.uiState.dataList.isEmpty
    ? null
    : () async {
    await viewModel.loadNextPageData();
    },
    child: ListView.builder(
    scrollDirection: Axis.vertical,
    itemExtent: itemExtent(),
    itemCount: itemCount(),
    shrinkWrap: true,
    itemBuilder: (BuildContext context, int index) {
    return item(context, index);
    },
    ),
    );
    }

    @override
    Widget empty() {
    return const Padding(
    padding: EdgeInsetsDirectional.only(
    top: 40,
    ),
    child: PageEmpty(),
    );
    }
    }

    封装通用视图

    空界面

    通用的空界面视图,中间展示图标和文本,适配黑白模式
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    class PageEmpty extends StatelessWidget {
    const PageEmpty({Key? key}) : super(key: key);

    @override
    Widget build(BuildContext context) {
    return Container(
    alignment: Alignment.topCenter,
    child: Column(
    mainAxisSize: MainAxisSize.min,
    crossAxisAlignment: CrossAxisAlignment.center,
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
    Image.asset(
    _provideImagePath(),
    width: 180,
    height: 180,
    ),
    const Padding(
    padding: EdgeInsetsDirectional.only(top: 12),
    ),
    Text(
    'empty',
    style: TextStyle(
    fontSize: 14,
    color: ColorConfig.emphasis60,
    ),
    ),
    ],
    ),
    );
    }

    String _provideImagePath() {
    bool isDark = Global.isDark ?? false;
    if (isDark) {
    return "resource/images/ic_no_record_dark.png";
    } else {
    return "resource/images/ic_no_record.png";
    }
    }
    }

    列表底部视图

    通用的列表底部视图,居中展示文本
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    class PageFooter extends Footer {
    final LinkFooterNotifier linkNotifier = LinkFooterNotifier();
    PageFooter({bool safeArea = true}) : super(safeArea: safeArea);

    @override
    Widget contentBuilder(
    BuildContext context,
    LoadMode loadState,
    double pulledExtent,
    double loadTriggerPullDistance,
    double loadIndicatorExtent,
    AxisDirection axisDirection,
    bool float,
    Duration? completeDuration,
    bool enableInfiniteLoad,
    bool success,
    bool noMore) {
    linkNotifier.contentBuilder(
    context,
    loadState,
    pulledExtent,
    loadTriggerPullDistance,
    loadIndicatorExtent,
    axisDirection,
    float,
    completeDuration,
    enableInfiniteLoad,
    success,
    noMore);
    return SafeArea(
    child: Container(
    height: 32,
    child: Center(
    child: Text(
    noMore ? 'no_more_data' : 'loading',
    textAlign: TextAlign.center,
    style: TextStyle(fontSize: 12, color: ColorConfig.emphasis40),
    ),
    ),
    ),
    );
    }
    }

    加载视图

    封装LoadingView

    引入Lottie到pubspec.yaml,传入文本并展示加载图标
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    class LoadingView extends StatefulWidget {
    final String tips;
    final Size size;

    const LoadingView({
    Key? key,
    this.tips = '',
    this.size = const Size.square(32),
    }) : super(key: key);

    @override
    State<LoadingView> createState() => _LoadingViewState();
    }

    class _LoadingViewState extends State<LoadingView> {
    @override
    Widget build(BuildContext context) {
    return UnconstrainedBox(
    child: Column(
    children: [
    Container(
    alignment: Alignment.center,
    width: widget.size.width,
    height: widget.size.height,
    child: Lottie.asset(
    'resource/lottie/loading.json',
    animate: true,
    ),
    ),
    widget.tips.isEmpty
    ? SizedBox()
    : Text(
    widget.tips,
    textAlign: TextAlign.center,
    style: TextStyle(
    fontSize: 12,
    fontFamily: 'URWDIN',
    fontWeight: FontWeight.w400,
    color: ColorConfig.emphasis,
    ),
    ),
    ],
    ),
    );
    }
    }
    通用的加载视图,中间展示LoadingView
    1
    2
    3
    4
    5
    6
    7
    8
    class PageLoading extends StatelessWidget {
    const PageLoading({Key? key}) : super(key: key);

    @override
    Widget build(BuildContext context) {
    return const Center(child: LoadingView(size: Size.square(50)));
    }
    }

    网络错误视图

    通用的网络错误,中间展示图标和文字暴露重试接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    class PageNetworkError extends StatelessWidget {
    final OnRetry? onRetry;

    const PageNetworkError({
    Key? key,
    this.onRetry,
    }) : super(key: key);

    @override
    Widget build(BuildContext context) {
    return GestureDetector(
    child: Container(
    alignment: Alignment.center,
    margin: EdgeInsetsDirectional.only(top: 50),
    child: Column(
    children: <Widget>[
    Image.asset(
    'resource/images/candy_net_error.png',
    width: 250,
    height: 100,
    ),
    Text(
    'network_error',
    style: TextStyle(fontSize: 14, color: ColorConfig.emphasis60),
    ),
    ],
    ),
    ),
    onTap: () => onRetry,
    );
    }
    }

    typedef OnRetry = void Function();

    应用场景

    普通页面-登录页面

    这里用wanandroid的api来举例

    登录页面LoginApi

    返回登录和注册的url
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class LoginApi {
    static LoginApi? _instance;

    LoginApi._internal();

    factory LoginApi.getInstance() {
    _instance ??= LoginApi._internal();
    return _instance!;
    }

    String getLoginUrl() {
    return "/user/login";
    }

    String getRegisterUrl() {
    return "/user/register";
    }
    }

    接口返回的bean类

    所有字段均可空,实现fromJson方法接收map实例返回实例和toJson方法返回序列化后的map对象,可以用插件JsonToDart也可以用在线转换[0][传送门]再拷贝过来
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    class LoginBean {
    LoginBean({
    this.admin,
    this.chapterTops,
    this.coinCount,
    this.collectIds,
    this.email,
    this.icon,
    this.id,
    this.nickname,
    this.password,
    this.publicName,
    this.token,
    this.type,
    this.username,
    });

    factory LoginBean.fromJson(Map<String, dynamic> json) {
    return LoginBean(
    admin: json['admin'],
    chapterTops:
    (json['chapterTops'] as List?)?.map((e) => e as String).toList(),
    coinCount: json['coinCount'],
    collectIds: (json['collectIds'] as List?)?.map((e) => e as int).toList(),
    email: json['email'],
    icon: json['icon'],
    id: json['id'],
    nickname: json['nickname'],
    password: json['password'],
    publicName: json['publicName'],
    token: json['token'],
    type: json['type'],
    username: json['username']);
    }

    bool? admin;
    List<dynamic>? chapterTops;
    int? coinCount;
    List<dynamic>? collectIds;
    String? email;
    String? icon;
    int? id;
    String? nickname;
    String? password;
    String? publicName;
    String? token;
    int? type;
    String? username;

    Map<String, dynamic> toJson() {
    final map = <String, dynamic>{};
    map['admin'] = admin;
    if (chapterTops != null) {
    map['chapterTops'] = chapterTops?.map((v) => v.toJson()).toList();
    }
    map['coinCount'] = coinCount;
    map['collectIds'] = collectIds?.map((v) => v.toJson()).toList();
    map['email'] = email;
    map['icon'] = icon;
    map['id'] = id;
    map['nickname'] = nickname;
    map['password'] = password;
    map['publicName'] = publicName;
    map['token'] = token;
    map['type'] = type;
    map['username'] = username;
    return map;
    }
    }

    定义BasePageViewModel实现类LoginLogic

    调用LoginRepository的login方法发起登录请求,onReady方法中可执行loadData执行请求任务列表
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    class LoginLogic extends BasePageViewModel<LoginUIState, LoginRepository> {
    LoginLogic({required super.uiState, required super.repository});

    @override
    String pageId() => LoginTrack.pageIdLogin;

    @override
    void onReady() {
    // TODO: implement onReady
    super.onReady();
    loadData();
    }

    void loadData() async {
    activeStatus(isLoading: true);
    await Future.wait<dynamic>([]);
    activeStatus(isLoading: false);
    }

    void login(String username, String password) async {
    LoginBean? loginBean = await repository?.login(username, password);
    showToast(loginBean != null ? '登录成功' : '登录失败');
    }
    }

    定义BaseBinding实现类LoginBinding

    初始化LoginRemoteDataSource实例,重写provideRepository方法注入LoginRepositoryImpl的实例和LoginUIState的实例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class LoginLogicBinding extends BaseBinding {
    @override
    void dependencies() {
    Get.lazyPut<LoginLogic>(() =>
    LoginLogic(repository: provideRepository(), uiState: LoginUIState()));
    }

    @override
    LoginRepository provideRepository() => LoginRepositoryImpl.getInstance(LoginRemoteDataSource.getInstance());
    }

    定义UIState实现类LoginUIState

    定义UI相关的变量,是否展示输入密码交互,当前是否可以登录
    1
    2
    3
    4
    class LoginUIState extends BasePageUIState {
    RxBool protect = false.obs;
    RxBool loginEnable = false.obs;
    }

    定义RemoteDataSource实现类LoginRemoteDataSource

    继承BaseRemoteDataSource,实现调用服务端login接口的方法并实现单例,利用封装的DioManager发起post请求
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    class LoginRemoteDataSource extends BaseRemoteDataSource {
    static LoginRemoteDataSource? _instance;

    LoginRemoteDataSource._internal();

    factory LoginRemoteDataSource.getInstance() {
    _instance ??= LoginRemoteDataSource._internal();
    return _instance!;
    }

    Future<LoginBean?> login(String username, String password) async {
    var requestParams = {
    'username': username,
    'password': password
    };
    BaseResult result = await dioManager.postRequest(
    LoginApi.getInstance().getLoginUrl(),
    params: requestParams,
    isForm: true);
    logPrint(LoginApi.getInstance().getLoginUrl(), result);
    return result.data == null ? null : LoginBean.fromJson(result.data);
    }
    }

    定义BaseRepository子类LoginRepository

    定义登录接口
    1
    2
    3
    abstract class LoginRepository extends BaseRepository {
    Future<LoginBean?> login(String username, String password);
    }

    定义RepositoryImpl子类LoginRepositoryImpl

    实现LoginRepository接口的登录方法调用服务端数据处理对象请求接口,传入LoginRemoteDataSource服务端数据处理对象类型
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class LoginRepositoryImpl
    extends BaseRepositoryImp<BaseLocalDataSource, LoginRemoteDataSource>
    implements LoginRepository {
    static LoginRepositoryImpl? _instance;

    LoginRepositoryImpl._internal({required super.remoteDataSource});

    factory LoginRepositoryImpl.getInstance(
    LoginRemoteDataSource loginRemoteDataSource) {
    _instance ??=
    LoginRepositoryImpl._internal(remoteDataSource: loginRemoteDataSource);
    return _instance!;
    }

    @override
    Future<LoginBean?> login(String username, String password) => remoteDataSource.login(username, password);
    }

    定义BasePage的子类LoginPage

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    class LoginPage extends BasePage<LoginLogic> {
    LoginLogic? loginLogic;
    String? username;
    String? password;

    LoginPage({Key? key}) : super(key: key) {
    loginLogic = GetInstance().find<LoginLogic>();
    }

    @override
    String titleString() {
    return "登录";
    }

    @override
    PreferredSizeWidget? appbar(BuildContext buildContext) {
    // TODO: implement appbar
    return AppBar(
    brightness: Global.isDark! ? Brightness.dark : Brightness.light,
    backgroundColor: appBarBackgroundColor,
    elevation: 0,
    centerTitle: true,
    titleSpacing: 0,
    leading: leading(buildContext),
    title: title(),
    actions: actions(),
    );
    }

    @override
    List<Widget>? actions() {
    return [
    GestureDetector(
    child: Container(
    height: 44,
    margin: EdgeInsetsDirectional.only(start: 12, end: 12),
    padding: EdgeInsetsDirectional.only(bottom: 5),
    alignment: AlignmentDirectional.center,
    child: Image.asset('resource/images/newcomer_home_rule.png',
    width: 24, height: 24, color: ColorConfig.emphasis),
    ),
    onTap: () {
    print('explainIcon');
    },
    ),
    ];
    }

    @override
    Widget content(BuildContext context) {
    return Container(
    child: ListView(
    children: [
    Obx(() => LoginEffect(protect: viewModel.uiState.protect.value)),
    LoginInput('用户名', '请输入用户名', onChanged: (text) {
    username = text;
    checkInput();
    }),
    LoginInput(
    '密码',
    '请输入密码',
    obscureText: true,
    onChanged: (text) {
    password = text;
    checkInput();
    },
    focusChanged: (focus) {
    viewModel.uiState.protect.value = focus;
    },
    ),
    Obx(() => Padding(
    padding: EdgeInsets.only(left: 20, right: 20, top: 10),
    child: LoginButton(
    '登录',
    enable: viewModel.uiState.loginEnable.value,
    onPressed: send,
    ),
    ))
    ],
    ),
    );
    }

    void checkInput() {
    bool enable;
    if (username?.isNotEmpty == true && password?.isNotEmpty == true) {
    enable = true;
    } else {
    enable = false;
    }
    viewModel.uiState.loginEnable.value = enable;
    }

    void send() async {
    if (username != null && password != null) {
    loginLogic?.login(username!, password!);
    }
    }
    }

    封装登录按钮

    封装编辑框下面的圆角登录按钮
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    class LoginButton extends StatelessWidget {
    final String title;
    final bool enable;
    final VoidCallback? onPressed;

    const LoginButton(this.title, {Key? key, this.enable = true, this.onPressed})
    : super(key: key);

    @override
    Widget build(BuildContext context) {
    return FractionallySizedBox(
    widthFactor: 1,
    child: MaterialButton(
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
    height: 45,
    onPressed: enable ? onPressed : null,
    disabledColor: ColorConfig.primary60,
    color: ColorConfig.primary,
    child: Text(title, style: TextStyle(color: Colors.white, fontSize: 16)),
    ),
    );
    }
    }

    封装登录页面顶部交互头

    传入密码输入时是否展示遮挡交互的标记
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    class LoginEffect extends StatefulWidget {
    final bool protect;

    const LoginEffect({Key? key, required this.protect}) : super(key: key);

    @override
    State<LoginEffect> createState() => _LoginEffectState();
    }

    class _LoginEffectState extends State<LoginEffect> {
    @override
    Widget build(BuildContext context) {
    return Container(
    padding: EdgeInsets.only(top: 10),
    decoration: BoxDecoration(
    color: Colors.grey[100],
    border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
    ),
    child: Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
    image(true),
    Image(
    height: 90,
    width: 90,
    image: AssetImage('resource/images/logo.png'),
    ),
    image(false)
    ],
    ),
    );
    }

    /**
    * 密码保护的交互图片
    */
    image(bool left) {
    var leftImg = widget.protect
    ? 'resource/images/head_left_protect.png'
    : 'resource/images/head_left.png';

    var rightImg = widget.protect
    ? 'resource/images/head_right_protect.png'
    : 'resource/images/head_right.png';

    return Image(height: 90, image: AssetImage(left ? leftImg : rightImg));
    }
    }

    封装登录用户名密码编辑框

    传入标题和提示文本,暴露文字/焦点改变的回调,是否有左边距,传入密码是否模糊
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    class LoginInput extends StatefulWidget {
    final String title;
    final String hint;
    final ValueChanged<String>? onChanged;
    final ValueChanged<bool>? focusChanged;
    final bool lineStretch;
    final bool obscureText;
    final TextInputType? keyboardType;

    const LoginInput(this.title, this.hint,
    {Key? key,
    this.onChanged,
    this.focusChanged,
    this.lineStretch = false,
    this.obscureText = false,
    this.keyboardType})
    : super(key: key);

    @override
    State<LoginInput> createState() => _LoginInputState();
    }

    class _LoginInputState extends State<LoginInput> {
    final focusNode = FocusNode();

    @override
    void initState() {
    super.initState();
    focusNode.addListener(() {
    print('Has focus: ${focusNode.hasFocus}');
    if (widget.focusChanged != null) {
    widget.focusChanged!(focusNode.hasFocus);
    }
    });
    }

    @override
    void dispose() {
    focusNode.dispose();
    super.dispose();
    }

    @override
    Widget build(BuildContext context) {
    return Column(
    children: [
    Row(
    children: [
    Container(
    padding: EdgeInsets.only(left: 15),
    width: 100,
    child: Text(
    widget.title,
    style: TextStyle(fontSize: 16),
    ),
    ),
    input()
    ],
    ),
    Padding(
    padding: EdgeInsets.only(left: !widget.lineStretch ? 15 : 0),
    child: Divider(
    height: 1,
    thickness: 0.5,
    )),
    ],
    );
    }

    input() {
    return Expanded(
    child: TextField(
    focusNode: focusNode,
    onChanged: widget.onChanged,
    obscureText: widget.obscureText,
    keyboardType: widget.keyboardType,
    autofocus: !widget.obscureText,
    cursorColor: ColorConfig.primary,
    style: TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
    decoration: InputDecoration(
    contentPadding: EdgeInsets.only(left: 20, right: 20),
    border: InputBorder.none,
    hintText: widget.hint,
    hintStyle: TextStyle(fontSize: 15, color: Colors.grey)),
    ));
    }
    }

    加载更多列表页面-WanAndroid

    首页HomeApi

    返回获取文章列表的url
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class HomeApi {
    static HomeApi? _instance;
    HomeApi._internal();
    factory HomeApi.getInstance() {
    _instance ??= HomeApi._internal();
    return _instance!;
    }

    String getArticleList(int currentPage) {
    return '/article/list/$currentPage/json';
    }
    }

    BaseLoadMoreListPageUIState的子类HomeUIState

    暂时没用到与UI显示有关的变量
    1
    class HomeUIState extends BaseLoadMoreListPageUIState<ArticleBean> {}

    BaseBinding的实现类首页Binding

    初始化HomeRemoteDataSource的实例,重写provideRepository方法,注入HomeRepositoryImpl的实例和HomeUIState的实例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class HomeBinding extends BaseBinding{
    @override
    void dependencies() {
    Get.lazyPut<HomeLogic>(() =>
    HomeLogic(repository: provideRepository(), uiState: HomeUIState()));
    }

    @override
    HomeRepository provideRepository() => HomeRepositoryImpl.getInstance(HomeRemoteDataSource.getInstance());

    }

    BaseLoadMoreListViewModel的实现类HomeLogic

    重写loadData方法调用repository的获取文章列表接口添加到BaseListPageUIState的dataList中,刷新是否有下一页标记,刷新下一次请求的页数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class HomeLogic extends BaseLoadMoreListViewModel<HomeUIState, HomeRepository> {
    HomeLogic({required super.repository, required super.uiState});

    @override
    Future<bool> loadData(bool isRefresh) async {
    var result = await repository?.getArticleList(currentPage);
    if (result == null) {
    return false;
    }
    if (isRefresh) {
    uiState.dataList.clear();
    }
    currentPage = result.curPage ?? 0;
    if (result.articles != null) {
    var list = result.articles?.map((e) => e as ArticleBean).toList();
    uiState.dataList.addAll(list ?? []);
    }
    hasNext = result.curPage != result.pageCount;
    return true;
    }
    }

    BaseRemoteDataSource的实现类HomeRemoteDataSource

    调用dioManager对象发起get请求获取文章列表
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class HomeRemoteDataSource extends BaseRemoteDataSource {
    static HomeRemoteDataSource? _instance;
    HomeRemoteDataSource._internal();

    factory HomeRemoteDataSource.getInstance(){
    _instance ??= HomeRemoteDataSource._internal();
    return _instance!;
    }

    Future<ArticleList?> getArticleList(int currentPage) async{
    String requestUrl = HomeApi.getInstance().getArticleList(currentPage);
    BaseResult result = await dioManager.getRequest(requestUrl);
    logPrint(requestUrl, result);
    return result.data == null ? null : ArticleList.fromJson(result.data);
    }

    logPrint(String api, BaseResult result){
    if (Global.isDebug) {
    print("Login >> $api \n ${jsonEncode(result)}");
    }
    }
    }

    BaseRepository的子类HomeRepository

    定义获取文章列表接口
    1
    2
    3
    abstract class HomeRepository extends BaseRepository{
    Future<ArticleList?> getArticleList(int currentPage);
    }

    BaseRepositoryImp的子类HomeRepositoryImpl实现HomeRepository接口

    重写getArticleList方法调用服务端数据处理对象请求文章列表接口,传入HomeRemoteDataSource服务端数据处理对象类型
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class HomeRepositoryImpl
    extends BaseRepositoryImp<BaseLocalDataSource, HomeRemoteDataSource>
    implements HomeRepository {
    static HomeRepositoryImpl? _instance;

    HomeRepositoryImpl._internal({required super.remoteDataSource});

    factory HomeRepositoryImpl.getInstance(
    HomeRemoteDataSource homeRemoteDataSource) {
    _instance ??=
    HomeRepositoryImpl._internal(remoteDataSource: homeRemoteDataSource);
    return _instance!;
    }

    @override
    Future<ArticleList?> getArticleList(int currentPage) =>
    remoteDataSource.getArticleList(currentPage);
    }

    封装文章列表的Bean对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    class ArticleList {
    ArticleList({
    this.curPage,
    this.articles,
    this.offset,
    this.over,
    this.pageCount,
    this.size,
    this.total,
    });

    factory ArticleList.fromJson(Map<String, dynamic> json) {
    return ArticleList(
    curPage: json['curPage'],
    articles: (json['datas'] as List?)?
    .map((e) => ArticleBean.fromJson(e))
    .toList(),
    offset: json['offset'],
    over: json['over'],
    pageCount: json['pageCount'],
    size: json['size'],
    total: json['total']);
    }

    int? curPage;
    List<dynamic>? articles;
    int? offset;
    bool? over;
    int? pageCount;
    int? size;
    int? total;

    Map<String, dynamic> toJson() {
    final map = <String, dynamic>{};
    map['curPage'] = curPage;
    if (articles != null) {
    map['datas'] = articles?.map((v) => v.toJson()).toList();
    }
    map['offset'] = offset;
    map['over'] = over;
    map['pageCount'] = pageCount;
    map['size'] = size;
    map['total'] = total;
    return map;
    }
    }

    class ArticleBean {
    ArticleBean({
    this.adminAdd,
    this.apkLink,
    this.audit,
    this.author,
    this.canEdit,
    this.chapterId,
    this.chapterName,
    this.collect,
    this.courseId,
    this.desc,
    this.descMd,
    this.envelopePic,
    this.fresh,
    this.host,
    this.id,
    this.isAdminAdd,
    this.link,
    this.niceDate,
    this.niceShareDate,
    this.origin,
    this.prefix,
    this.projectLink,
    this.publishTime,
    this.realSuperChapterId,
    this.selfVisible,
    this.shareDate,
    this.shareUser,
    this.superChapterId,
    this.superChapterName,
    this.tags,
    this.title,
    this.type,
    this.userId,
    this.visible,
    this.zan,
    });

    factory ArticleBean.fromJson(Map<String, dynamic> json) {
    return ArticleBean(
    adminAdd: json['adminAdd'],
    apkLink: json['apkLink'],
    audit: json['audit'],
    author: json['author'],
    canEdit: json['canEdit'],
    chapterId: json['chapterId'],
    chapterName: json['chapterName'],
    collect: json['collect'],
    courseId: json['courseId'],
    desc: json['desc'],
    descMd: json['descMd'],
    envelopePic: json['envelopePic'],
    fresh: json['fresh'],
    host: json['host'],
    id: json['id'],
    isAdminAdd: json['isAdminAdd'],
    link: json['link'],
    niceDate: json['niceDate'],
    niceShareDate: json['niceShareDate'],
    origin: json['origin'],
    prefix: json['prefix'],
    projectLink: json['projectLink'],
    publishTime: json['publishTime'],
    realSuperChapterId: json['realSuperChapterId'],
    selfVisible: json['selfVisible'],
    shareDate: json['shareDate'],
    shareUser: json['shareUser'],
    superChapterId: json['superChapterId'],
    superChapterName: json['superChapterName'],
    tags: (json['tags'] as List?)?.map((e) => Tag.fromJson(e)).toList(),
    title: json['title'],
    type: json['type'],
    userId: json['userId'],
    visible: json['visible'],
    zan: json['zan']);
    }

    bool? adminAdd;
    String? apkLink;
    int? audit;
    String? author;
    bool? canEdit;
    int? chapterId;
    String? chapterName;
    bool? collect;
    int? courseId;
    String? desc;
    String? descMd;
    String? envelopePic;
    bool? fresh;
    String? host;
    int? id;
    bool? isAdminAdd;
    String? link;
    String? niceDate;
    String? niceShareDate;
    String? origin;
    String? prefix;
    String? projectLink;
    int? publishTime;
    int? realSuperChapterId;
    int? selfVisible;
    dynamic shareDate;
    String? shareUser;
    int? superChapterId;
    String? superChapterName;
    List<dynamic>? tags;
    String? title;
    int? type;
    int? userId;
    int? visible;
    int? zan;

    Map<String, dynamic> toJson() {
    final map = <String, dynamic>{};
    map['adminAdd'] = adminAdd;
    map['apkLink'] = apkLink;
    map['audit'] = audit;
    map['author'] = author;
    map['canEdit'] = canEdit;
    map['chapterId'] = chapterId;
    map['chapterName'] = chapterName;
    map['collect'] = collect;
    map['courseId'] = courseId;
    map['desc'] = desc;
    map['descMd'] = descMd;
    map['envelopePic'] = envelopePic;
    map['fresh'] = fresh;
    map['host'] = host;
    map['id'] = id;
    map['isAdminAdd'] = isAdminAdd;
    map['link'] = link;
    map['niceDate'] = niceDate;
    map['niceShareDate'] = niceShareDate;
    map['origin'] = origin;
    map['prefix'] = prefix;
    map['projectLink'] = projectLink;
    map['publishTime'] = publishTime;
    map['realSuperChapterId'] = realSuperChapterId;
    map['selfVisible'] = selfVisible;
    map['shareDate'] = shareDate;
    map['shareUser'] = shareUser;
    map['superChapterId'] = superChapterId;
    map['superChapterName'] = superChapterName;
    if (tags != null) {
    map['tags'] = tags?.map((v) => v.toJson()).toList();
    }
    map['title'] = title;
    map['type'] = type;
    map['userId'] = userId;
    map['visible'] = visible;
    map['zan'] = zan;
    return map;
    }
    }

    class Tag {
    Tag({
    this.name,
    this.url,
    });

    factory Tag.fromJson(Map<String, dynamic> json) {
    return Tag(name: json['name'], url: json['url']);
    }

    String? name;
    String? url;

    Map<String, dynamic> toJson() {
    final map = <String, dynamic>{};
    map['name'] = name;
    map['url'] = url;
    return map;
    }
    }

    BasePage的实现类HomePage

    重写首页展示标题,重写item方法返回列表项视图
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class HomePage extends BaseLoadMoreListPage<HomeLogic> {
    HomeLogic? homeLogic;

    HomePage({Key? key}) : super(key: key) {
    homeLogic = GetInstance().find<HomeLogic>();
    }

    @override
    String titleString() {
    return '首页';
    }

    @override
    Widget item(BuildContext context, int index) {
    return ArticleItemView(
    articleBean: viewModel.uiState.dataList[index],
    onClickCollect: (collect) {
    print('click collect');
    });
    }
    }

    封装文章列表项

    外部Widget传入ViewModel中的UIState中的dataList中的某条ArticleBean数据项,传入回调方法onClickCollect,定义collect通过GetX的Obx机制结合obs变量刷新局部视图
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    class ArticleItemView extends StatefulWidget {
    final ArticleBean articleBean;
    final OnClickCollect onClickCollect;
    RxBool collect = false.obs;

    ArticleItemView({
    Key? key,
    required this.articleBean,
    required this.onClickCollect,
    });

    @override
    State<ArticleItemView> createState() => _ArticleItemViewState();
    }

    class _ArticleItemViewState extends State<ArticleItemView> {
    void itemClick(ArticleBean articleBean) {}

    void itemCollect(ArticleBean articleBean) {}

    @override
    Widget build(BuildContext context) {
    String authorTitle;
    String author;

    if (widget.articleBean.author == null ||
    widget.articleBean.author!.isEmpty) {
    authorTitle = "分享人:";
    author = widget.articleBean.shareUser ?? "";
    } else {
    authorTitle = "作者:";
    author = widget.articleBean.author ?? "";
    }
    Row row = Row(
    mainAxisAlignment: MainAxisAlignment.start,
    children: <Widget>[
    Expanded(
    child: Text(
    "$authorTitle$author",
    style: TextStyle(color: ColorConfig.emphasis, fontSize: 13),
    )),
    Text(
    widget.articleBean.niceDate ?? "",
    style: TextStyle(color: ColorConfig.emphasis, fontSize: 13),
    ),
    ],
    );
    Row title = Row(
    children: <Widget>[
    Expanded(
    child: Text.rich(
    TextSpan(text: widget.articleBean.title ?? ""),
    softWrap: true,
    style: TextStyle(fontSize: 16.0, color: ColorConfig.emphasis),
    textAlign: TextAlign.left,
    )),
    ],
    );
    Row chapterName = Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
    Container(
    padding: EdgeInsets.only(left: 4, right: 4, top: 1, bottom: 2),
    decoration: BoxDecoration(
    color: ColorConfig.darkOverLay,
    borderRadius: BorderRadius.circular(2)),
    child: Text(
    widget.articleBean.chapterName ?? "",
    softWrap: true,
    style: TextStyle(color: ColorConfig.emphasis60, fontSize: 12),
    textAlign: TextAlign.left,
    ),
    ),
    Obx(() => GestureDetector(
    child: Icon(
    widget.collect.value ? Icons.favorite : Icons.favorite_border,
    color: widget.collect.value ? Colors.red : null,
    ),
    onTap: () {
    widget.articleBean.collect =
    !widget.articleBean.collect.notNull();
    widget.collect.value = widget.articleBean.collect.notNull();
    widget.onClickCollect
    .call(widget.articleBean.collect.notNull());
    },
    ))
    ],
    );

    Column column = Column(
    children: [
    title,
    SizedBox(
    height: 5,
    ),
    row,
    SizedBox(
    height: 5,
    ),
    chapterName
    ],
    );

    return InkWell(
    onTap: () {
    itemClick(widget.articleBean);
    },
    child: Container(
    padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
    decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(4),
    ),
    margin: EdgeInsets.all(4),
    child: column,
    ));
    }
    }

    typedef OnClickCollect = void Function(bool collect);


又是很长一段时间没有更新blog,最近疫情又严重了,公司开启了WFH模式,闲暇之余有更多的时间来做技术总结和沉淀,最近在项目中遇到一个需求,绘制一个六位的密码输入框,需要用到自定义View的相关知识,中间还出了一点小插曲,在某些机型引发了一个bug在此做一个记录,欢迎交流和讨论~


最终效果图:
效果图

自定义LinearLayout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PassWordView extends LinearLayout {

public PassWordView(@NonNull Context context) {
super(context);
init();
}

public PassWordView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}

public PassWordView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init(){

}
}

添加EditText和TextView

  • 在LinearLayout中添加1个EditText在底部,EditText的输入格式只能是数字或密码且输入长度为6,当前LinearLayout点击后触发EditText获取焦点并弹出软键盘
  • 监听输入过程把输入的文本替换为”●”
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    private int defaultSize = 6;
    private EditText editText;

    // 清空当前输入的密码
    public void clearPassWord() {
    editText.setText(null);
    }

    @SuppressLint("CheckResult")
    private void init() {
    editText = new EditText(getContext());
    editText.setTextSize(12f);
    editText.setLayoutParams(new FrameLayout.LayoutParams(1, ViewGroup.LayoutParams.WRAP_CONTENT));
    editText.setCursorVisible(false);
    editText.setBackgroundDrawable(null);
    editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
    addView(editText);
    editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(defaultSize)});
    setOnClickListener(v -> {
    if (editText.requestFocus()) {
    InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
    if (imm != null) {
    imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
    }
    }
    });

    editText.addTextChangedListener(new TextWatcher() {
    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {

    }

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
    TextView childAt = (TextView) getChildAt(start + 1); // 获取当前聚焦的TextView
    if (before == 0) {
    childAt.setText("●");
    } else {
    childAt.setText("");
    }
    }

    @Override
    public void afterTextChanged(Editable s) {

    }
    });
    }

    // 获取当前密码
    public String getPwd() {
    return editText.getText().toString();
    }


    public void setFocusShape(boolean showFocusShape) {
    this.showFocusShape = showFocusShape;
    }

    添加TextView

  • 添加6个正方形的TextView在顶部,来实现我们的六位密码框输入布局,自定义TextView在onMeasure中设置宽等于高
  • 定义单个密码框之间间隔为6,第一个密码框marginStart为0,marginEnd为5/6 * space,第二个密码框marginStart为1/6 * space,marginEnd为4/6 * space,第三个密码框marginStart为2/6 * space,marginEnd为3/6 * space,前一个密码框的marginEnd + marginStart = space,所以marginStart = space * i / defaultSize,marginEnd = space * (defaultSize - 1 - i) / defaultSize
  • 设置当前输入的密码框聚焦背景

    小插曲

    这里最开始的实现思路有问题,把TextView的weight设置为并动态添加,对当前LinearLayout的onMeasure方法做了处理,获取每个TextView的宽度再设置其高度导致应用到底部弹窗布局时,小米机型上由于TextView的高度做了调整比调整前的高度更高,使得软件盘顶上去的高度不够,下面的文本被遮挡。但是在华为等机型上又表现正常,可能跟Rom的处理有关(这些机型上又自动刷新了一次)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    private boolean showFocusShape = true; // 是否设置聚焦背景
    @SuppressLint("CheckResult")
    private void init(){
    ...
    double space = SizeUtils.dp2px(6);
    for (int i = 0; i < defaultSize; i++) {
    TextView textView = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.single_pwd_editext, null);
    LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, 1);
    layoutParams.setMarginStart((int) (space * i / defaultSize));
    layoutParams.setMarginEnd((int) (space * (defaultSize - i - 1) / defaultSize));
    addView(textView, layoutParams);
    }
    editText.setOnFocusChangeListener((v, hasFocus) -> {
    if (!showFocusShape) {
    return;
    }
    for (int i = 1; i < getChildCount(); i++) {
    getChildAt(i).setBackgroundResource(hasFocus ? R.drawable.shape_editext_border_green_2r : R.drawable.shape_edittext_border_background_with_error);
    }
    });
    ...
    }

    public static class SquareTextView extends AppCompatTextView {

    public SquareTextView(Context context) {
    super(context);
    }

    public SquareTextView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    if (widthMode == MeasureSpec.UNSPECIFIED) {
    throw new RuntimeException("mode is UNSPECIFIED");
    } else {
    setMeasuredDimension(widthSize, widthSize);
    }
    }
    }
  • shape_editext_border_green_2r.xml
    1
    2
    3
    4
    5
    6
    7
    8
    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/emphasis4" />
    <stroke
    android:width="@dimen/line_width"
    android:color="@color/primary" />
    <corners android:radius="2dp" />
    </shape>
  • shape_edittext_border_background_with_error.xml
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <?xml version="1.0" encoding="utf-8"?>
    <selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_activated="true">
    <shape>
    <solid android:color="@color/emphasis4" />
    <stroke android:width="@dimen/line_width" android:color="@color/secondary" />
    <corners android:radius="6dp" />
    </shape>
    </item>
    <item android:state_activated="false">
    <shape>
    <solid android:color="@color/emphasis4" />
    <corners android:radius="6dp" />
    </shape>
    </item>
    </selector>
  • single_pwd_editext中引用自定义TextView
    1
    2
    3
    4
    5
    6
    7
    <?xml version="1.0" encoding="utf-8"?>
    <view class="com.resources.widget.PassWordView$SquareTextView" xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/shape_edittext_border_background_with_error"
    android:gravity="center"
    android:textColor="@color/c_text60" />

    定义回调返回密码输入完成的数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    private PasswordCallback passwordCallback;
    interface PassWordCallback {
    void complete(String password)
    }

    public void setPasswordCallback(PasswordCallback passwordCallback) {
    this.passwordCallback = passwordCallback;
    }

    @SuppressLint("CheckResult")
    private void init(){
    ...
    editText.addTextChangedListener(new TextWatcher() {
    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {

    }

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
    TextView childAt = (TextView) getChildAt(start + 1);
    if (before == 0) { // 新增情况下
    childAt.setText("●");
    } else {
    childAt.setText("");
    }
    if (!TextUtils.isEmpty(s) && s.length() == defaultSize) {
    if (passwordCallback != null) {
    try {
    passwordCallback.complete(s.toString());
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }
    }

    @Override
    public void afterTextChanged(Editable s) {

    }
    });
    ...
    }

最后附上完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
public class PassWordView extends LinearLayout {

private int defaultSize = 6;
private PasswordCallback passwordCallback;
interface PassWordCallback {
void complete(String password)
}

public void setPasswordCallback(PasswordCallback passwordCallback) {
this.passwordCallback = passwordCallback;
}
private boolean showFocusShape = true;
private EditText editText;

public PassWordView(@NonNull Context context) {
super(context);
init();
}

public PassWordView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}

public PassWordView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

public void clearPassWord() {
editText.setText(null);
}

@SuppressLint("CheckResult")
private void init() {
editText = new EditText(getContext());
editText.setTextSize(12f);
editText.setLayoutParams(new FrameLayout.LayoutParams(1, ViewGroup.LayoutParams.WRAP_CONTENT));
editText.setCursorVisible(false);
editText.setBackgroundDrawable(null);
editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
addView(editText);
editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(defaultSize)});
setOnClickListener(v -> {
if (editText.requestFocus()) {
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
}
}
});

double space = SizeUtils.dp2px(6);
for (int i = 0; i < defaultSize; i++) {
TextView textView = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.single_pwd_editext, null);
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, 1);
layoutParams.setMarginStart((int) (space * i / defaultSize));
layoutParams.setMarginEnd((int) (space * (defaultSize - i - 1) / defaultSize));
addView(textView, layoutParams);
}
editText.setOnFocusChangeListener((v, hasFocus) -> {
if (!showFocusShape) {
return;
}
for (int i = 1; i < getChildCount(); i++) {
getChildAt(i).setBackgroundResource(hasFocus ? R.drawable.shape_editext_border_green_2r : R.drawable.shape_edittext_border_background_with_error);
}
});
editText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
TextView childAt = (TextView) getChildAt(start + 1);
if (before == 0) {
childAt.setText("●");
} else {
childAt.setText("");
}
if (!TextUtils.isEmpty(s) && s.length() == defaultSize) {
if (passwordCallback != null) {
try {
passwordCallback.complete(s.toString());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

@Override
public void afterTextChanged(Editable s) {

}
});
}

public String getPwd() {
return editText.getText().toString();
}


public void setFocusShape(boolean showFocusShape) {
this.showFocusShape = showFocusShape;
}

public static class SquareTextView extends AppCompatTextView {

public SquareTextView(Context context) {
super(context);
}

public SquareTextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode == MeasureSpec.UNSPECIFIED) {
throw new RuntimeException("mode is UNSPECIFIED");
} else {
setMeasuredDimension(widthSize, widthSize);
}
}
}
}

在xml中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/c_large"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp">

<TextView
android:id="@+id/tv1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/input_pwd"
android:textColor="@color/c_text"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<ImageView
android:id="@+id/iv_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingBottom="12dp"
android:src="@mipmap/icon_close"
app:tint="@color/emphasis60"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />


<com.resources.widget.PassWordView
android:id="@+id/password_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/tv1"
android:layout_marginTop="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv1"
app:layout_goneMarginTop="20dp" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/password_view"
android:paddingTop="10dp"
android:paddingBottom="12dp"
android:text="@string/tips_input"
android:textColor="@color/secondary"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password_view" />

<TextView
android:id="@+id/tv_forget_pwd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/password_view"
android:paddingTop="10dp"
android:paddingBottom="12dp"
android:text="@string/forget_pwd"
android:textColor="@color/c_text60"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password_view" />

</androidx.constraintlayout.widget.ConstraintLayout>


又是更新blog的一天,最近项目中遇到一个需求,绘制一个渐变色的仪表盘去展示某个行情当前的热度,为用户提供当前市场热度更直观的一个大概参考,需要用到自定义View的相关知识,在这里做一个总结,欢迎一起学习和讨论~


最终效果图:
效果图

变量声明

  • 声明画笔,颜色,刻度数,属性动画,进度条宽度,表盘矩形区域,文本大小,必要的偏移量,表盘的开始和过渡角度
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    var progressColor = intArrayOf() // 渐变色开始颜色
    var progressBackgroundColor = R.color.emphasis16 // 进度条背景颜色
    var textColor = R.color.primary // 文字颜色
    var tickScaleColor = R.color.emphasis8 // 普通刻度线颜色
    var groupScaleColor = R.color.emphasis38 // 分组刻度线颜色
    var progressStrokeWidth = 24f // 进度条宽度
    var paintProgressBackground = Paint() // 进度条背景画笔
    private var paintProgress = Paint() // 进度条画笔
    var paintText = Paint() // 文字画笔
    private var paintNum = Paint() // 刻度画笔
    var rect = RectF() // 表盘矩形区域
    private var viewWidth = 0 // 宽度
    private var viewHeight = 0 // 高度
    var percent = 0f // 百分比
    var oldPercent = 0f // 过去的百分比
    var textSize = 100f // 文本大小
    private var valueAnimator: ValueAnimator? = null // 属性动画
    var animatorDuration = 0L // 动画时长
    private var groupNum = 5 // 分组数
    private var ticksNum = 6 // 每组刻度数
    var pointerWidth = 15f // 指针的宽度
    private var ticksCount = groupNum * ticksNum + 1// 总刻度数

    companion object {
    var OFFSET = 30f // 偏移量
    var START_ARC = 150f // 开始角度
    var DURING_ARC = 240f // 过渡调度
    }

初始化逻辑

  • 获取xml的配置进行变量初始化
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    init {
    setLayerType(LAYER_TYPE_SOFTWARE, null)
    val typedArray = context.obtainStyledAttributes(attrs, R.styleable.dashboard)
    progressBackgroundColor = typedArray.getColor(
    R.styleable.dashboard_progressBackgroundColor,
    ContextCompat.getColor(context, progressBackgroundColor)
    )
    progressStrokeWidth = typedArray.getDimension(
    R.styleable.dashboard_progressStrokeWidth,
    progressStrokeWidth
    )

    textSize = typedArray.getDimension(
    R.styleable.dashboard_textSize,
    textSize
    )
    textColor = typedArray.getColor(R.styleable.dashboard_textColor, textColor)
    tickScaleColor =
    typedArray.getColor(R.styleable.dashboard_tickScaleColor, tickScaleColor)
    groupScaleColor =
    typedArray.getColor(R.styleable.dashboard_groupScaleColor, groupScaleColor)
    groupNum = typedArray.getInt(R.styleable.dashboard_groupNum, groupNum)
    ticksNum = typedArray.getInt(R.styleable.dashboard_ticksNum, ticksNum)
    pointerWidth =
    typedArray.getDimension(R.styleable.dashboard_pointerWidth, pointerWidth)
    val colorsId = typedArray.getResourceId(R.styleable.dashboard_progressColors, 0)
    progressColor =typedArray.resources.getIntArray(colorsId)
    ticksCount = groupNum * ticksNum + 1
    typedArray.recycle()
    OFFSET = progressStrokeWidth + 10f
    initPaint()
    }

    /**
    * 初始化画笔
    */
    private fun initPaint() {
    paintProgressBackground.isAntiAlias = true
    paintProgressBackground.strokeWidth = progressStrokeWidth
    paintProgressBackground.style = Paint.Style.STROKE
    paintProgressBackground.color = progressBackgroundColor
    paintProgressBackground.isDither = true
    paintProgress.isAntiAlias = true
    paintProgress.strokeWidth = progressStrokeWidth
    paintProgress.style = Paint.Style.STROKE
    paintProgress.isDither = true
    paintText.isAntiAlias = true
    paintText.color = textColor
    paintText.strokeWidth = 1F
    paintText.style = Paint.Style.FILL
    paintText.isDither = true
    paintNum.isAntiAlias = true
    paintNum.strokeWidth = 3f
    paintNum.style = Paint.Style.FILL
    paintNum.isDither = true
    }

    声明xml配置属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <declare-styleable name="dashboard">
    <attr name="progressStrokeWidth" format="dimension" />
    <attr name="progressBackgroundColor" format="color" />
    <attr name="textColor" format="color" />
    <attr name="tickScaleColor" format="color" />
    <attr name="groupScaleColor" format="color" />
    <attr name="groupNum" format="integer" />
    <attr name="ticksNum" format="integer" />
    <attr name="pointerWidth" format="dimension" />
    <attr name="textSize" format="dimension" />
    <attr name="progressColors" format="reference"/>
    </declare-styleable>

    获取自定义View宽高,初始化渐变进度条

    回顾一下View的生命周期

    1
    activity.onCreate -> onFinishInflate -> activity.onStart -> activity.onResume -> onAttachedToWindow -> onWindowVisibilityChanged -> onVisibilityChanged -> onMeasure -> onSizeChanged -> onLayout -> onDraw -> onWindowFocusChanged -> activity.onPause -> onWindowFocusChanged -> onWindowVisibilityChanged -> activity.onStop -> onVisibilityChanged -> activity.onDestroy -> onDetachedFromWindow
  • 重写onSizeChanged,此时自定义View已经完成测量可以拿到当前自定义View的宽高
  • 初始化指针宽度,当前变盘的矩形区域,以0,0点为原点,左边上角的坐标,x为-(View宽度 / 2)+ 偏移量 + paddingLeft,y为- (view高度 / 2) + 偏移量 + paddingTop,右下角的坐标,x为(View宽度 / 2)- 偏移量 - paddingRight,y为(view高度 / 2) - 偏移量 - paddingBottom,渐变色采用以原点为中心的90度渐变,初始化Shader的子类SweepGradient并设置一个以原点为中心的90度旋转操作后的矩阵,再设置这个Shader为画笔的Shader
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    viewWidth = width
    viewHeight = height
    pointerWidth = (viewWidth / 40).toFloat()
    initShader()
    }

    /**
    * 初始化渐变颜色
    */
    private fun initShader() {
    rect.set(
    (-viewWidth / 2) + OFFSET + paddingLeft, paddingTop - (viewHeight / 2) + OFFSET,
    (viewWidth / 2) - paddingRight - OFFSET,
    (viewWidth / 2) - paddingBottom - OFFSET
    )
    val shader = SweepGradient(
    0f,
    0f,
    progressColor,
    null
    )
    val rotate = 90f
    val gradientMatrix = Matrix()
    gradientMatrix.preRotate(rotate, 0f, 0f)
    shader.setLocalMatrix(gradientMatrix)
    paintProgress.shader = shader
    }

    测量View的宽高

  • 根据当前的模式和大小决定View大小,如果是明确指明宽高的大小就用当前声明的值,否则就设置一个固定值使得宽高一致防止自定义View形变
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val realWidth = startMeasure(widthMeasureSpec)
    val realHeight = startMeasure(heightMeasureSpec)
    setMeasuredDimension(realWidth, realHeight)
    }

    fun startMeasure(msSpec: Int): Int {
    val mode = MeasureSpec.getMode(msSpec)
    val size = MeasureSpec.getSize(msSpec)
    return if (mode == MeasureSpec.EXACTLY) {
    size
    } else {
    Util.dp2px(200)
    }
    }

    重写绘制方法

  • 当前的仪表盘的绘制逻辑分为绘制表盘刻度,绘制进度条,绘制文本,绘制指针,canvas的原点为左上角,为了方便计算在绘制前需要把原点移动到canvas的中心,所以x方向平移(View宽度 / 2),y方向平移(View高度 / 2)
    1
    2
    3
    4
    5
    6
    7
    8
    override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas?.translate((viewWidth / 2).toFloat(), (viewHeight / 2).toFloat())
    drawPanel(canvas)
    drawProgress(canvas, percent)
    drawText(canvas, percent)
    drawPointer(canvas, percent)
    }

    绘制表盘刻度

  • 以0,0点为圆心,绘制起始点逆时针旋转-120度,计算刻度的y坐标为 -(View高度 / 2) + 偏移量 + 进度条宽度,计算每个刻度的旋转角度为过渡角度 / (总刻度数 - 1),再以总刻度数进行循环绘制刻度线条,判断当前下标是否为新一组开始刻度的位置,区分普通刻度和分割刻度并设置不同的颜色
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /**
    * 绘制刻度
    * @param canvas Canvas?
    */
    fun drawPanel(canvas: Canvas?) {
    canvas?.save()
    canvas?.rotate(-(180 - START_ARC + 90), 0f, 0f)
    val numY = -viewHeight / 2 + OFFSET + progressStrokeWidth
    val angle = DURING_ARC / ((ticksCount - 1) * 1.0f)
    for (i in 0 until ticksCount) {
    canvas?.save()
    canvas?.rotate(angle * i, 0f, 0f)
    if (i == 0 || i % groupNum == 0) {
    paintNum.color = groupScaleColor
    } else {
    paintNum.color = tickScaleColor
    }
    canvas?.drawLine(0f, numY + 2, 0f, numY + (pointerWidth * 2) + 5, paintNum)
    canvas?.restore()
    }
    canvas?.restore()
    }

    绘制进度条

  • 先绘制背景进度条,绘制一个圆弧,绘制区域为表盘矩形区域,设置开始角度和过渡角度
  • 绘制进度条,判断当前的进度百分比如果大于1则为1,过滤掉其他异常情况,如果百分比大于0绘制一个渐变色圆弧,根据当前百分比和过渡角度计算当前需要绘制的渐变色过渡角度
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
    * 绘制进度
    * @param canvas Canvas?
    * @param percent Float
    */
    private fun drawProgress(canvas: Canvas?, percent: Float) {
    canvas?.drawArc(rect, START_ARC, DURING_ARC, false, paintProgressBackground)
    var curPercent = percent
    if (curPercent > 1.0f) {
    curPercent = 1.0f
    }
    if (curPercent > 0.0f) {
    canvas?.drawArc(rect, START_ARC, percent * DURING_ARC, false, paintProgress)
    }
    }

    绘制文本

  • 根据当前的进度百分比设置文本的内容和颜色,根据当前的View宽度计算一个Y方向的偏移量,如果百分比小于0.2展示“Very Cold”文本,渐变色数组中取第一个颜色,绘制文本的x坐标为文本宽度/2,第一个词y坐标为1.4倍偏移量,第二个词为2.4倍偏移量,如果百分比大于等于0.2并且小于0.4展示“Cold”文本,y坐标为2.2倍偏移量,如果百分比大于等于0.4并且小于0.6展示“Normal”文本,y坐标为2.2倍偏移量,如果百分比大于等于0.6并且小于0.8展示“Hot”文本,y坐标为2.2倍偏移量,如果是剩下的情况展示“Very Hot”文本,第一个词y坐标为1.4倍偏移量,第二个词为2.4倍偏移量
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    /**
    * 绘制文本
    * @param canvas Canvas?
    * @param percent Float
    */
    @SuppressLint("RestrictedApi")
    fun drawText(canvas: Canvas?, percent: Float) {
    val offsetY = viewWidth / 8
    paintText.textSize = textSize
    if (percent < 0.2f) {
    val subTitle1 = "Very"
    paintText.color = progressColor[0]
    canvas?.drawText(
    subTitle1,
    -paintText.measureText(subTitle1) / 2,
    offsetY * 1.4f,
    paintText
    )

    val subTitle2 = "Cold"
    canvas?.drawText(
    subTitle2,
    -paintText.measureText(subTitle2) / 2,
    offsetY * 2.4f,
    paintText
    )
    } else if (percent >= 0.2f && percent < 0.4f) {
    val subTitle = "Cold"
    paintText.color = progressColor[0]
    canvas?.drawText(
    subTitle,
    -paintText.measureText(subTitle) / 2,
    offsetY * 2.2f,
    paintText
    )
    } else if (percent >= 0.4f && percent < 0.6f) {
    val subTitle = "Normal"
    paintText.color = progressColor[2]
    canvas?.drawText(
    subTitle,
    -paintText.measureText(subTitle) / 2,
    offsetY * 2.2f,
    paintText
    )
    } else if (percent >= 0.6f && percent < 0.8f) {
    val subTitle = "Hot"
    paintText.color = progressColor[4]
    canvas?.drawText(
    subTitle,
    -paintText.measureText(subTitle) / 2,
    offsetY * 2.2f,
    paintText
    )
    } else {
    val subTitle1 = "Very"
    paintText.color = progressColor[4]
    canvas?.drawText(
    subTitle1,
    -paintText.measureText(subTitle1) / 2,
    offsetY * 1.4f,
    paintText
    )

    val subTitle2 = "Hot"
    canvas?.drawText(
    subTitle2,
    -paintText.measureText(subTitle2) / 2,
    offsetY * 2.4f,
    paintText
    )
    }

    paintText.textSize = (textSize * 1.3).toFloat()
    val text = (percent * 100).toInt().toString()
    canvas?.drawText(
    text,
    -paintText.measureText(text) / 2,
    - textSize / 5f,
    paintText
    )
    }

    绘制指针

  • 绘制一个三角形的指针,根据当前的百分比计算指针所在位置的旋转角度,再计算指针所在位置需要绘制三角形的路径,从(0, view的高度/2 - 偏移量 - 进度条宽度)为起点,绘制一个倒三角形,绘制右边的线段和左边的线段再绘制顶部的线段,以垂直向下为0度,旋转范围是-300到-60并设置填充模式绘制这条路径
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    /**
    * 绘制指针
    * @param canvas Canvas?
    * @param percent Float
    */
    private fun drawPointer(canvas: Canvas?, percent: Float) {
    canvas?.save()
    val angle = DURING_ARC * (percent - 0.5f) - 180
    canvas?.rotate(angle, 0f, 0f)
    val pointer = Path()
    pointer.moveTo(0f, viewHeight / 2 - OFFSET - progressStrokeWidth)
    pointer.lineTo(
    pointerWidth,
    viewHeight / 2 - OFFSET - progressStrokeWidth - (pointerWidth * 2)
    )
    pointer.lineTo(
    -pointerWidth,
    viewHeight / 2 - OFFSET - progressStrokeWidth - (pointerWidth * 2)
    )
    pointer.lineTo(0f, viewHeight / 2 - OFFSET - progressStrokeWidth)
    pointer.close()
    pointer.fillType = Path.FillType.EVEN_ODD
    canvas?.drawPath(pointer, paintText)
    canvas?.restore()
    }

设置百分比和动画

  • 传入当前的百分比范围0~1,初始化属性动画设置属性值变化监听刷新当前百分比,根据上一次的百分比和当前百分比计算动画时间,设置动画开始结束的百分比数值,设置动画结束监听刷新上一次百分比数值并做边界判断。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    /**
    * 设置当前百分比
    * @param curPercent Float 范围0~1
    */
    fun setProgress(curPercent: Float) {
    if (valueAnimator?.isRunning == true) {
    valueAnimator?.cancel()
    }
    animatorDuration = (abs(curPercent - oldPercent) * 20).toLong()
    valueAnimator = ValueAnimator.ofFloat(oldPercent, curPercent).setDuration(animatorDuration)
    valueAnimator?.addUpdateListener {
    percent = it.animatedValue as Float
    invalidate()
    }
    valueAnimator?.interpolator = LinearInterpolator()
    valueAnimator?.addListener(onEnd = {
    oldPercent = curPercent
    if (percent < 0.0f) {
    percent = 0.0f
    invalidate()
    }
    if (percent > 1f) {
    percent = 1f
    invalidate()
    }
    })
    valueAnimator?.start()
    }

    使用

  • xml声明
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <com.example.testapp.DashboardView
    android:id="@+id/view_dashboard"
    android:layout_width="200dp"
    android:layout_height="200dp"
    app:layout_constraintTop_toTopOf="parent"
    android:layout_marginStart="6dp"
    android:layout_marginTop="8dp"
    app:groupNum="5"
    app:groupScaleColor="@color/emphasis38"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:pointerWidth="2dp"
    app:progressBackgroundColor="@color/emphasis8"
    app:progressStrokeWidth="6dp"
    app:progressColors="@array/dashboardColors"
    app:textSize="24sp"
    app:tickScaleColor="@color/emphasis8"
    app:ticksNum="6" />
  • 设置当前百分比
    1
    dashboardView.setProgress((float) i / 100)
    最后附上完整代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    package com.example.testapp

    import android.animation.ValueAnimator
    import android.annotation.SuppressLint
    import android.content.Context
    import android.graphics.*
    import android.util.AttributeSet
    import android.view.View
    import android.view.animation.LinearInterpolator
    import androidx.core.animation.addListener
    import androidx.core.content.ContextCompat
    import kotlin.math.abs

    @SuppressLint("CustomViewStyleable")
    class DashboardView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
    ) :
    View(context, attrs, defStyleAttr) {
    var progressColor = intArrayOf() // 渐变色开始颜色
    var progressBackgroundColor = R.color.emphasis16 // 进度条背景颜色
    var textColor = R.color.primary // 文字颜色
    var tickScaleColor = R.color.emphasis8 // 普通刻度线颜色
    var groupScaleColor = R.color.emphasis38 // 分组刻度线颜色
    var progressStrokeWidth = 24f // 进度条宽度
    var paintProgressBackground = Paint() // 进度条背景画笔
    private var paintProgress = Paint() // 进度条画笔
    var paintText = Paint() // 文字画笔
    private var paintNum = Paint() // 刻度画笔
    var rect = RectF() // 表盘矩形区域
    private var viewWidth = 0 // 宽度
    private var viewHeight = 0 // 高度
    var percent = 0f // 百分比
    var oldPercent = 0f // 过去的百分比
    var textSize = 100f // 文本大小
    private var valueAnimator: ValueAnimator? = null // 属性动画
    var animatorDuration = 0L // 动画时长
    private var groupNum = 5 // 分组数
    private var ticksNum = 6 // 每组刻度数
    var pointerWidth = 15f // 指针的宽度
    private var ticksCount = groupNum * ticksNum + 1// 总刻度数

    companion object {
    var OFFSET = 30f // 偏移量
    var START_ARC = 150f // 开始角度
    var DURING_ARC = 240f // 过渡调度
    }

    init {
    setLayerType(LAYER_TYPE_SOFTWARE, null)
    val typedArray = context.obtainStyledAttributes(attrs, R.styleable.dashboard)
    progressBackgroundColor = typedArray.getColor(
    R.styleable.dashboard_progressBackgroundColor,
    ContextCompat.getColor(context, progressBackgroundColor)
    )
    progressStrokeWidth = typedArray.getDimension(
    R.styleable.dashboard_progressStrokeWidth,
    progressStrokeWidth
    )

    textSize = typedArray.getDimension(
    R.styleable.dashboard_textSize,
    textSize
    )
    textColor = typedArray.getColor(R.styleable.dashboard_textColor, textColor)
    tickScaleColor =
    typedArray.getColor(R.styleable.dashboard_tickScaleColor, tickScaleColor)
    groupScaleColor =
    typedArray.getColor(R.styleable.dashboard_groupScaleColor, groupScaleColor)
    groupNum = typedArray.getInt(R.styleable.dashboard_groupNum, groupNum)
    ticksNum = typedArray.getInt(R.styleable.dashboard_ticksNum, ticksNum)
    pointerWidth =
    typedArray.getDimension(R.styleable.dashboard_pointerWidth, pointerWidth)
    val colorsId = typedArray.getResourceId(R.styleable.dashboard_progressColors, 0)
    progressColor =typedArray.resources.getIntArray(colorsId)
    ticksCount = groupNum * ticksNum + 1
    typedArray.recycle()
    OFFSET = progressStrokeWidth + 10f
    initPaint()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val realWidth = startMeasure(widthMeasureSpec)
    val realHeight = startMeasure(heightMeasureSpec)
    setMeasuredDimension(realWidth, realHeight)
    }

    fun startMeasure(msSpec: Int): Int {
    val mode = MeasureSpec.getMode(msSpec)
    val size = MeasureSpec.getSize(msSpec)
    return if (mode == MeasureSpec.EXACTLY) {
    size
    } else {
    Util.dp2px(200)
    }
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    viewWidth = width
    viewHeight = height
    pointerWidth = (viewWidth / 40).toFloat()
    initShader()
    }

    override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas?.translate((viewWidth / 2).toFloat(), (viewHeight / 2).toFloat())
    drawPanel(canvas)
    drawProgress(canvas, percent)
    drawText(canvas, percent)
    drawPointer(canvas, percent)
    }

    /**
    * 绘制刻度
    * @param canvas Canvas?
    */
    fun drawPanel(canvas: Canvas?) {
    canvas?.save()
    canvas?.rotate(-(180 - START_ARC + 90), 0f, 0f)
    val numY = -viewHeight / 2 + OFFSET + progressStrokeWidth
    val angle = DURING_ARC / ((ticksCount - 1) * 1.0f)
    for (i in 0 until ticksCount) {
    canvas?.save()
    canvas?.rotate(angle * i, 0f, 0f)
    if (i == 0 || i % groupNum == 0) {
    paintNum.color = groupScaleColor
    } else {
    paintNum.color = tickScaleColor
    }
    canvas?.drawLine(0f, numY + 2, 0f, numY + (pointerWidth * 2) + 5, paintNum)
    canvas?.restore()
    }
    canvas?.restore()
    }

    /**
    * 绘制进度
    * @param canvas Canvas?
    * @param percent Float
    */
    private fun drawProgress(canvas: Canvas?, percent: Float) {
    canvas?.drawArc(rect, START_ARC, DURING_ARC, false, paintProgressBackground)
    var curPercent = percent
    if (curPercent > 1.0f) {
    curPercent = 1.0f
    }
    if (curPercent > 0.0f) {
    canvas?.drawArc(rect, START_ARC, percent * DURING_ARC, false, paintProgress)
    }
    }

    /**
    * 绘制指针
    * @param canvas Canvas?
    * @param percent Float
    */
    private fun drawPointer(canvas: Canvas?, percent: Float) {
    canvas?.save()
    val angle = DURING_ARC * (percent - 0.5f) - 180
    canvas?.rotate(angle, 0f, 0f)
    val pointer = Path()
    pointer.moveTo(0f, viewHeight / 2 - OFFSET - progressStrokeWidth)
    pointer.lineTo(
    pointerWidth,
    viewHeight / 2 - OFFSET - progressStrokeWidth - (pointerWidth * 2)
    )
    pointer.lineTo(
    -pointerWidth,
    viewHeight / 2 - OFFSET - progressStrokeWidth - (pointerWidth * 2)
    )
    pointer.lineTo(0f, viewHeight / 2 - OFFSET - progressStrokeWidth)
    pointer.close()
    pointer.fillType = Path.FillType.EVEN_ODD
    canvas?.drawPath(pointer, paintText)
    canvas?.restore()
    }

    /**
    * 绘制文本
    * @param canvas Canvas?
    * @param percent Float
    */
    @SuppressLint("RestrictedApi")
    fun drawText(canvas: Canvas?, percent: Float) {
    val offsetY = viewWidth / 8
    paintText.textSize = textSize
    if (percent < 0.2f) {
    val subTitle1 = "Very"
    paintText.color = progressColor[0]
    canvas?.drawText(
    subTitle1,
    -paintText.measureText(subTitle1) / 2,
    offsetY * 1.4f,
    paintText
    )

    val subTitle2 = "Cold"
    canvas?.drawText(
    subTitle2,
    -paintText.measureText(subTitle2) / 2,
    offsetY * 2.4f,
    paintText
    )
    } else if (percent >= 0.2f && percent < 0.4f) {
    val subTitle = "Cold"
    paintText.color = progressColor[0]
    canvas?.drawText(
    subTitle,
    -paintText.measureText(subTitle) / 2,
    offsetY * 2.2f,
    paintText
    )
    } else if (percent >= 0.4f && percent < 0.6f) {
    val subTitle = "Normal"
    paintText.color = progressColor[2]
    canvas?.drawText(
    subTitle,
    -paintText.measureText(subTitle) / 2,
    offsetY * 2.2f,
    paintText
    )
    } else if (percent >= 0.6f && percent < 0.8f) {
    val subTitle = "Hot"
    paintText.color = progressColor[4]
    canvas?.drawText(
    subTitle,
    -paintText.measureText(subTitle) / 2,
    offsetY * 2.2f,
    paintText
    )
    } else {
    val subTitle1 = "Very"
    paintText.color = progressColor[4]
    canvas?.drawText(
    subTitle1,
    -paintText.measureText(subTitle1) / 2,
    offsetY * 1.4f,
    paintText
    )

    val subTitle2 = "Hot"
    canvas?.drawText(
    subTitle2,
    -paintText.measureText(subTitle2) / 2,
    offsetY * 2.4f,
    paintText
    )
    }

    paintText.textSize = (textSize * 1.3).toFloat()
    val text = (percent * 100).toInt().toString()
    canvas?.drawText(
    text,
    -paintText.measureText(text) / 2,
    - textSize / 5f,
    paintText
    )
    }

    /**
    * 设置当前百分比
    * @param curPercent Float 范围0~1
    */
    fun setProgress(curPercent: Float) {
    if (valueAnimator?.isRunning == true) {
    valueAnimator?.cancel()
    }
    animatorDuration = (abs(curPercent - oldPercent) * 20).toLong()
    valueAnimator = ValueAnimator.ofFloat(oldPercent, curPercent).setDuration(animatorDuration)
    valueAnimator?.addUpdateListener {
    percent = it.animatedValue as Float
    invalidate()
    }
    valueAnimator?.interpolator = LinearInterpolator()
    valueAnimator?.addListener(onEnd = {
    oldPercent = curPercent
    if (percent < 0.0f) {
    percent = 0.0f
    invalidate()
    }
    if (percent > 1f) {
    percent = 1f
    invalidate()
    }
    })
    valueAnimator?.start()
    }

    /**
    * 设置进度条宽度
    * @param dp Int
    */
    fun setProgressStroke(dp: Int) {
    progressStrokeWidth = Util.dp2px(dp).toFloat()
    paintProgress.strokeWidth = progressStrokeWidth
    paintProgressBackground.strokeWidth = progressStrokeWidth
    invalidate()
    }



    /**
    * 初始化画笔
    */
    private fun initPaint() {
    paintProgressBackground.isAntiAlias = true
    paintProgressBackground.strokeWidth = progressStrokeWidth
    paintProgressBackground.style = Paint.Style.STROKE
    paintProgressBackground.color = progressBackgroundColor
    paintProgressBackground.isDither = true
    paintProgress.isAntiAlias = true
    paintProgress.strokeWidth = progressStrokeWidth
    paintProgress.style = Paint.Style.STROKE
    paintProgress.isDither = true
    paintText.isAntiAlias = true
    paintText.color = textColor
    paintText.strokeWidth = 1F
    paintText.style = Paint.Style.FILL
    paintText.isDither = true
    paintNum.isAntiAlias = true
    paintNum.strokeWidth = 3f
    paintNum.style = Paint.Style.FILL
    paintNum.isDither = true
    }

    /**
    * 初始化渐变颜色
    */
    private fun initShader() {
    rect.set(
    (-viewWidth / 2) + OFFSET + paddingLeft, paddingTop - (viewHeight / 2) + OFFSET,
    (viewWidth / 2) - paddingRight - OFFSET,
    (viewWidth / 2) - paddingBottom - OFFSET
    )
    val shader = SweepGradient(
    0f,
    0f,
    progressColor,
    null
    )
    val rotate = 90f
    val gradientMatrix = Matrix()
    gradientMatrix.preRotate(rotate, 0f, 0f)
    shader.setLocalMatrix(gradientMatrix)
    paintProgress.shader = shader
    }
    }


最近工作比较忙,项目压力大,又是很长一段时间没有更新blog了。今天虽然是放五一长假,但是由于疫情仍然没有结束,所以没有出游的计划,正好可以写点东西总结一下最近项目中遇到的自定义View相关的内容,实现一个总量一定,支持和反对人数对比的指示条来反映某个问题下大多数人的看法,欢迎交流和讨论~


最终效果图:
效果图

变量声明

  • 声明支持和反对的绘制画笔,数量以及颜色,定义指示条的宽度,中间间隔的宽度,以及三角分割的宽度,圆角大小,指示条坐标对象
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    private var downPaint = Paint()
    private var upPaint = Paint()
    private var upCount = 80
    private var downCount = 0
    private var downColor = ContextCompat.getColor(context, R.color.secondary)
    private var upColor = ContextCompat.getColor(context, R.color.primary)
    private var lineWidth = Util.dp2px(6)
    private var attachWidth = Util.dp2px(2)
    private var spanWidth = Util.dp2px(9)
    private var roundSize = lineWidth - Util.dp2px(2)
    private val indicator by lazy { Indicator() }

    指示条坐标对象

  • 整个指示条包含8个坐标,支持段和反对段各4个坐标,所以封装为一个Indicator对象,把支持和反对的绘制路径封装成两个IndicatorPath对象,每个IndicatorPath包含4个坐标,每个坐标包含x,y的数值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    data class Indicator(
    var upPath: IndicatorPath = IndicatorPath(),
    var downPath: IndicatorPath = IndicatorPath()
    )

    data class IndicatorPath(
    var startTop: Pos = Pos(),
    var startBottom: Pos = Pos(),
    var endTop: Pos = Pos(),
    var endBottom: Pos = Pos()
    )

    data class Pos(var x: Float = 0f, var y: Float = 0f)

    初始化逻辑

  • 获取xml的配置进行变量初始化
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    init {
    setLayerType(View.LAYER_TYPE_SOFTWARE, null)
    val typedArray = context.obtainStyledAttributes(attrs, R.styleable.compareindicator)
    downColor = typedArray.getColor(R.styleable.compareindicator_down_color, downColor)
    upColor = typedArray.getColor(R.styleable.compareindicator_up_color, upColor)
    lineWidth = typedArray.getDimension(
    R.styleable.compareindicator_lineWidth,
    lineWidth.toFloat()
    ).toInt()
    attachWidth = typedArray.getDimension(
    R.styleable.compareindicator_attachWidth,
    lineWidth.toFloat()
    ).toInt()
    spanWidth = typedArray.getDimension(
    R.styleable.compareindicator_spanWidth,
    lineWidth.toFloat()
    ).toInt()
    textSize = typedArray.getDimension(
    R.styleable.dashboard_textSize,
    textSize
    )
    typedArray.recycle()
    upPaint = Paint()
    upPaint.color = upColor
    downPaint = Paint()
    downPaint.color = downColor
    }

    重写onDraw自定义绘制逻辑

  • 当支持和反对数量均不为0时,根据当前View宽度和支持数量百分比计算支持指示条的长度,根据当前View宽度和反对数量百分比计算反对指示条的长度,再计算支持和反对指示条的绘制路径。如果支持数不为0反对数为0则只绘制支持指示条,设置支持绘制画笔的圆角,直接绘制一根长度为View宽度的支持指示条,如果支持数为0反对数不为0则只绘制反对指示条,设置反对绘制画笔的圆角,直接绘制一根长度为View宽度的反对指示条。如果支持和反对数都为0,则支持和反对指示条长度各占View宽度的一半进行绘制
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    val upWidth: Int
    val downWidth: Int
    if (upCount != 0 && downCount != 0) {
    upWidth = width / (downCount + upCount) * upCount
    downWidth = width / (downCount + upCount) * downCount
    getPath(downWidth)
    drawIndicator(canvas)
    } else if (upCount != 0 && downCount == 0) {
    upWidth = width
    upPaint.strokeWidth = lineWidth.toFloat()
    upPaint.strokeCap = Paint.Cap.ROUND
    canvas?.drawLine(
    (paddingStart + roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    (upWidth - paddingEnd - roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    upPaint
    )
    } else if (upCount == 0 && downCount != 0) {
    downWidth = width
    downPaint.strokeWidth = lineWidth.toFloat()
    downPaint.strokeCap = Paint.Cap.ROUND
    canvas?.drawLine(
    (paddingStart + roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    (downWidth - paddingEnd - roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    downPaint
    )
    } else {
    upWidth = width / 2
    downWidth = width / 2
    getPath(downWidth)
    drawIndicator(canvas)
    }
    drawText(canvas)
    }

    计算绘制路径

  • 分别计算支持指示条和反对指示条的绘制路径,各自计算开始和结束的上下坐标,这里考虑到圆角,所以左边的反对指示条x开始坐标为View的paddingStart+圆角的大小,x结束坐标为View的paddingStart+圆角的大小+反对指示条的长度+三角形分割的宽度-间隔的宽度,y开始坐标为View的paddingTop,y结束坐标为View的paddingTop+指示条的宽度。右边的支持指示条x开始坐标为反对指示条的x坐标+间隔宽度,结束坐标为View的宽度-View的paddingEnd-圆角大小,y开始坐标为paddingTop,结束坐标为paddingTop+指示条的宽度
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    private fun getPath(downWidth: Int) {
    val paddingSpanStart = paddingStart + roundSize
    // 反对指示条
    indicator.downPath.startTop.x = paddingSpanStart.toFloat()
    indicator.downPath.startTop.y = paddingTop.toFloat()
    indicator.downPath.startBottom.x = paddingSpanStart.toFloat()
    indicator.downPath.startBottom.y = (paddingTop + lineWidth).toFloat()
    indicator.downPath.endTop.x =
    (paddingSpanStart + downWidth + attachWidth - spanWidth).toFloat()
    indicator.downPath.endTop.y = paddingTop.toFloat()
    indicator.downPath.endBottom.x = (paddingSpanStart + downWidth - spanWidth).toFloat()
    indicator.downPath.endBottom.y = (paddingTop + lineWidth).toFloat()
    val paddingSpanEnd = paddingEnd + roundSize
    // 支持指示条
    indicator.upPath.startTop.x = indicator.downPath.endTop.x + spanWidth
    indicator.upPath.startTop.y = paddingTop.toFloat()
    indicator.upPath.startBottom.x = indicator.downPath.endBottom.x + spanWidth
    indicator.upPath.startBottom.y = (paddingTop + lineWidth).toFloat()
    indicator.upPath.endTop.x = (width - paddingSpanEnd).toFloat()
    indicator.upPath.endTop.y = paddingTop.toFloat()
    indicator.upPath.endBottom.x = (width - paddingSpanEnd).toFloat()
    indicator.upPath.endBottom.y = (paddingTop + lineWidth).toFloat()
    }

    绘制指示条逻辑

  • 设置支持和反对的绘制画笔为填充模式,绘制反对指示条的路径,从开始的上坐标到结束的上坐标,从结束的下坐标到开始的下坐标都是绘制直线,开始的下坐标到开始的上坐标是绘制圆滑的曲线即贝塞尔曲线,控制点为x的位置是开始的下坐标x-圆角大小,y是开始的下坐标y-指示线的宽度/2,结束点为开始的上坐标。同理,绘制支持指示条的路径,从结束的上坐标到开始的上坐标,从开始的下坐标到结束的下坐标都是绘制直线,结束的下坐标到结束的上坐标同样是绘制贝塞尔曲线,控制点为x的位置是结束的下坐标x-圆角大小,y是结束的下坐标y-指示线的宽度/2,结束点为结束的上坐标。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    private fun drawIndicator(canvas: Canvas?) {
    downPaint.strokeWidth = 1F
    upPaint.strokeWidth = 1F
    downPaint.strokeCap = Paint.Cap.BUTT
    upPaint.strokeCap = Paint.Cap.BUTT
    drawLine(canvas, indicator.downPath, downPaint, true)
    drawLine(canvas, indicator.upPath, upPaint, false)
    }

    private fun drawLine(
    canvas: Canvas?,
    indicatorPath: IndicatorPath,
    paint: Paint,
    down: Boolean
    ) {
    val path = Path()
    if (down) {
    path.moveTo(indicatorPath.startTop.x, indicatorPath.startTop.y)
    path.lineTo(indicatorPath.endTop.x, indicatorPath.endTop.y)
    path.lineTo(indicatorPath.endBottom.x, indicatorPath.endBottom.y)
    path.lineTo(indicatorPath.startBottom.x, indicatorPath.startBottom.y)
    path.quadTo(
    indicatorPath.startBottom.x - roundSize,
    indicatorPath.startBottom.y - (lineWidth / 2),
    indicatorPath.startTop.x,
    indicatorPath.startTop.y
    )
    } else {
    path.moveTo(indicatorPath.endTop.x, indicatorPath.endTop.y)
    path.lineTo(indicatorPath.startTop.x, indicatorPath.startTop.y)
    path.lineTo(indicatorPath.startBottom.x, indicatorPath.startBottom.y)
    path.lineTo(indicatorPath.endBottom.x, indicatorPath.endBottom.y)
    path.quadTo(
    indicatorPath.endBottom.x + roundSize,
    indicatorPath.endBottom.y - (lineWidth / 2),
    indicatorPath.endTop.x,
    indicatorPath.endTop.y
    )
    }
    path.close()
    paint.strokeWidth = 1f
    paint.style = Paint.Style.FILL
    path.fillType = Path.FillType.WINDING
    canvas?.drawPath(path, paint)
    }

    绘制文本逻辑

  • 左边是反对指示条在开始位置绘制Down的数量文本,x的坐标为反对指示条的开始x坐标,y坐标为反对指示条的开始下坐标y+间隔宽度+文字大小,右边是支持指示条在结束位置绘制Up的数量文本,x的坐标为支持指示条的结束下坐标的x-文本宽度,y坐标为支持指示条的结束下坐标y+间隔宽度+文字大小
    1
    2
    3
    4
    5
    6
    7
    8
    private fun drawText(canvas: Canvas?){
    val downText = "Down: $downCount"
    downPaint.textSize = textSize
    canvas?.drawText(downText, indicator.downPath.startBottom.x, indicator.downPath.startBottom.y + spanWidth + textSize, downPaint)
    val upText = "Up: $upCount"
    upPaint.textSize = textSize
    canvas?.drawText(upText, indicator.upPath.endBottom.x - upPaint.measureText(upText), indicator.upPath.endBottom.y + spanWidth + textSize, upPaint)
    }

    设置支持和反对数量

  • 根据设置的支持反对数量刷新视图
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    fun updateView(
    up: Int,
    down: Int,
    downColor: Int = ContextCompat.getColor(context, R.color.secondary),
    upColor: Int = ContextCompat.getColor(context, R.color.primary)
    ) {
    this.upCount = up
    this.downCount = down
    this.downColor = downColor
    downPaint.color = downColor
    this.upColor = upColor
    upPaint.color = upColor
    postInvalidate()
    }

    使用

  • xml中声明
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <com.example.testapp.CompareIndicator
    android:id="@+id/indicator_compare"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginStart="12dp"
    android:layout_marginTop="200dp"
    android:layout_marginEnd="12dp"
    app:lineWidth="6dp"
    app:textSize="12sp"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
  • 设置支持反对数量
    1
    compareIndicator.updateView(80,20, ContextCompat.getColor(this, R.color.secondary), ContextCompat.getColor(this, R.color.primary))
    最后附上完整代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    package com.example.testapp

    import android.content.Context
    import android.graphics.Canvas
    import android.graphics.Paint
    import android.graphics.Path
    import android.util.AttributeSet
    import android.view.View
    import androidx.core.content.ContextCompat

    class CompareIndicator @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {
    private var downPaint = Paint()
    private var upPaint = Paint()
    private var upCount = 80
    private var downCount = 0
    private var downColor = ContextCompat.getColor(context, R.color.secondary)
    private var upColor = ContextCompat.getColor(context, R.color.primary)
    private var lineWidth = Util.dp2px(6)
    private var attachWidth = Util.dp2px(2)
    private var spanWidth = Util.dp2px(9)
    private var labelSize = 40f
    private var roundSize = lineWidth - Util.dp2px(2)
    private val indicator by lazy { Indicator() }

    init {
    setLayerType(View.LAYER_TYPE_SOFTWARE, null)
    val typedArray = context.obtainStyledAttributes(attrs, R.styleable.compareindicator)
    downColor = typedArray.getColor(R.styleable.compareindicator_down_color, downColor)
    upColor = typedArray.getColor(R.styleable.compareindicator_up_color, upColor)
    lineWidth = typedArray.getDimension(
    R.styleable.compareindicator_lineWidth,
    lineWidth.toFloat()
    ).toInt()
    attachWidth = typedArray.getDimension(
    R.styleable.compareindicator_attachWidth,
    lineWidth.toFloat()
    ).toInt()
    spanWidth = typedArray.getDimension(
    R.styleable.compareindicator_spanWidth,
    lineWidth.toFloat()
    ).toInt()
    labelSize = typedArray.getDimension(
    R.styleable.dashboard_textSize,
    labelSize
    )
    typedArray.recycle()
    upPaint = Paint()
    upPaint.color = upColor
    downPaint = Paint()
    downPaint.color = downColor
    }

    fun updateView(
    up: Int,
    down: Int,
    downColor: Int = ContextCompat.getColor(context, R.color.secondary),
    upColor: Int = ContextCompat.getColor(context, R.color.primary)
    ) {
    this.upCount = up
    this.downCount = down
    this.downColor = downColor
    downPaint.color = downColor
    this.upColor = upColor
    upPaint.color = upColor
    postInvalidate()
    }

    override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    val upWidth: Int
    val downWidth: Int
    if (upCount != 0 && downCount != 0) {
    upWidth = width / (downCount + upCount) * upCount
    downWidth = width / (downCount + upCount) * downCount
    getPath(downWidth)
    drawIndicator(canvas)
    } else if (upCount != 0 && downCount == 0) {
    upWidth = width
    upPaint.strokeWidth = lineWidth.toFloat()
    upPaint.strokeCap = Paint.Cap.ROUND
    canvas?.drawLine(
    (paddingStart + roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    (upWidth - paddingEnd - roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    upPaint
    )
    } else if (upCount == 0 && downCount != 0) {
    downWidth = width
    downPaint.strokeWidth = lineWidth.toFloat()
    downPaint.strokeCap = Paint.Cap.ROUND
    canvas?.drawLine(
    (paddingStart + roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    (downWidth - paddingEnd - roundSize).toFloat(),
    (paddingTop + roundSize).toFloat(),
    downPaint
    )
    } else {
    upWidth = width / 2
    downWidth = width / 2
    getPath(downWidth)
    drawIndicator(canvas)
    }
    drawText(canvas)
    }

    private fun drawText(canvas: Canvas?){
    val downText = "Down: $downCount"
    downPaint.textSize = labelSize
    canvas?.drawText(downText, indicator.downPath.startBottom.x, indicator.downPath.startBottom.y + spanWidth + labelSize, downPaint)
    val upText = "Up: $upCount"
    upPaint.textSize = labelSize
    canvas?.drawText(upText, indicator.upPath.endBottom.x - upPaint.measureText(upText), indicator.upPath.endBottom.y + spanWidth + labelSize, upPaint)
    }

    private fun drawIndicator(canvas: Canvas?) {
    downPaint.strokeWidth = 1F
    upPaint.strokeWidth = 1F
    downPaint.strokeCap = Paint.Cap.BUTT
    upPaint.strokeCap = Paint.Cap.BUTT
    drawLine(canvas, indicator.downPath, downPaint, true)
    drawLine(canvas, indicator.upPath, upPaint, false)
    }

    private fun drawLine(
    canvas: Canvas?,
    indicatorPath: IndicatorPath,
    paint: Paint,
    down: Boolean
    ) {
    val path = Path()
    if (down) {
    path.moveTo(indicatorPath.startTop.x, indicatorPath.startTop.y)
    path.lineTo(indicatorPath.endTop.x, indicatorPath.endTop.y)
    path.lineTo(indicatorPath.endBottom.x, indicatorPath.endBottom.y)
    path.lineTo(indicatorPath.startBottom.x, indicatorPath.startBottom.y)
    path.quadTo(
    indicatorPath.startBottom.x - roundSize,
    indicatorPath.startBottom.y - (lineWidth / 2),
    indicatorPath.startTop.x,
    indicatorPath.startTop.y
    )
    } else {
    path.moveTo(indicatorPath.endTop.x, indicatorPath.endTop.y)
    path.lineTo(indicatorPath.startTop.x, indicatorPath.startTop.y)
    path.lineTo(indicatorPath.startBottom.x, indicatorPath.startBottom.y)
    path.lineTo(indicatorPath.endBottom.x, indicatorPath.endBottom.y)
    path.quadTo(
    indicatorPath.endBottom.x + roundSize,
    indicatorPath.endBottom.y - (lineWidth / 2),
    indicatorPath.endTop.x,
    indicatorPath.endTop.y
    )
    }
    path.close()
    paint.strokeWidth = 1f
    paint.style = Paint.Style.FILL
    path.fillType = Path.FillType.WINDING
    canvas?.drawPath(path, paint)
    }

    private fun getPath(downWidth: Int) {
    val paddingSpanStart = paddingStart + roundSize
    // 支持指示条
    indicator.downPath.startTop.x = paddingSpanStart.toFloat()
    indicator.downPath.startTop.y = paddingTop.toFloat()
    indicator.downPath.startBottom.x = paddingSpanStart.toFloat()
    indicator.downPath.startBottom.y = (paddingTop + lineWidth).toFloat()
    indicator.downPath.endTop.x =
    (paddingSpanStart + downWidth + attachWidth - spanWidth).toFloat()
    indicator.downPath.endTop.y = paddingTop.toFloat()
    indicator.downPath.endBottom.x = (paddingSpanStart + downWidth - spanWidth).toFloat()
    indicator.downPath.endBottom.y = (paddingTop + lineWidth).toFloat()
    val paddingSpanEnd = paddingEnd + roundSize
    // 反对指示条
    indicator.upPath.startTop.x = indicator.downPath.endTop.x + spanWidth
    indicator.upPath.startTop.y = paddingTop.toFloat()
    indicator.upPath.startBottom.x = indicator.downPath.endBottom.x + spanWidth
    indicator.upPath.startBottom.y = (paddingTop + lineWidth).toFloat()
    indicator.upPath.endTop.x = (width - paddingSpanEnd).toFloat()
    indicator.upPath.endTop.y = paddingTop.toFloat()
    indicator.upPath.endBottom.x = (width - paddingSpanEnd).toFloat()
    indicator.upPath.endBottom.y = (paddingTop + lineWidth).toFloat()
    }

    data class Indicator(
    var upPath: IndicatorPath = IndicatorPath(),
    var downPath: IndicatorPath = IndicatorPath()
    )

    data class IndicatorPath(
    var startTop: Pos = Pos(),
    var startBottom: Pos = Pos(),
    var endTop: Pos = Pos(),
    var endBottom: Pos = Pos()
    )

    data class Pos(var x: Float = 0f, var y: Float = 0f)
    }


裁剪是Android开发中一个常见的需求,虽然Android的ImageView的scaleType属性提供了多种图片的展示方式,其中包括一些裁剪方式,以及Glide自带的Transformation也提供了常见的圆角,圆形,居中裁剪,但有的时候仍然不能满足我们的需求,比如说为了版本迭代老版本apk能兼容新版本的图片资源,UI迭代的情况下产品会提出裁剪图片保留图片底部区域的规则,这时候ImageView和Glide自带的裁剪就不能满足需求了,需要我们自定义Transformation实现新的裁剪规则


裁剪类型

定义枚举:顶部裁剪,居中裁剪,底部裁剪

1
2
3
enum class CropType {
TOP, CENTER, BOTTOM
}

实线BitmapTransformation接口

默认居中裁剪,支持传入自定义的裁剪像素宽高,重写必要的方法,updateDiskCacheKey,transform,equals,hashCode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private val TAG: String = CropTransformation::class.java.name

class CropTransformation(var width: Int = 0, var height: Int = 0, var cropType: CropType = CropType.CENTER) : BitmapTransformation() {

override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(TAG.toByteArray(CHARSET))
}

override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
...
}

override fun equals(other: Any?): Boolean {
return other is CropTransformation && other.width == width && other.height == height && other.cropType == cropType
}

override fun hashCode(): Int {
return TAG.hashCode()
}
}

实现核心方法transform

  • transform的入参为,图片复用线程池pool,下载后要处理的图片toTransform,目标View的宽高

  • 当宽或高为0时默认使用图片的宽或高

  • 图片格式默认采用要处理图片的格式,否则使用ARGB_8888

  • 从当前图片缓存池中取得一张复用的图片进行处理,打开alpha通道

  • 计算裁剪的宽和图片的宽之比,以及高之比,取大的那个

  • 计算缩放后的宽和高

  • 计算绘制的开始位置,x和y坐标,x坐标为(当前裁剪的宽度 - 缩放后的宽度)/ 2,从水平居中区域开始绘制,y坐标根据当前的裁剪类型而定,TOP从0开始,CENTER为(当前裁剪的高度 - 缩放后的高度)/ 2,从垂直居中区域开始,BOTTOM直接裁剪底部区域,结束位置的x坐标为开始位置的x坐标 + 缩放后的宽度,y坐标为开始位置的y坐标 + 缩放后的高度,最后计算出绘制的矩形区域,设置从当前图片缓存池中取得的图片密度为要处理的图片toTransform的密度,利用复用的图片创建canvas,drawBitmap把要处理的图片toTransform绘制到canvas的目标矩形区域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
width = if (width == 0) toTransform.width else width
height = if (height == 0) toTransform.height else height

val config = if (toTransform.config != null) toTransform.config else Bitmap.Config.ARGB_8888
val bitmap = pool[width, height, config]

bitmap.setHasAlpha(true)

val scaleX = width.toFloat() / toTransform.width
val scaleY = height.toFloat() / toTransform.height
val scale = max(scaleX, scaleY)

val scaledWidth = scale * toTransform.width
val scaledHeight = scale * toTransform.height
val left = (width - scaledWidth) / 2
val top: Float = getTop(scaledHeight)
val targetRect = RectF(left, top, left + scaledWidth, top + scaledHeight)

bitmap.density = toTransform.density
val canvas = Canvas(bitmap)
canvas.drawBitmap(toTransform, null, targetRect, null)

return bitmap
}

private fun getTop(scaledHeight: Float): Float {
return when (cropType) {
CropType.TOP -> 0f
CropType.CENTER -> (height - scaledHeight) / 2
CropType.BOTTOM -> height - scaledHeight
}
}

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package com.kubi.kucoin.utils

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.RectF
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.security.MessageDigest
import kotlin.math.max

/**
* @description: 图片裁剪
* @author: Jessie.Li
* @email: jessie.li@corp.kucoin.com
* @create: 2022-01-05 17:47
**/
private val TAG: String = CropTransformation::class.java.name

class CropTransformation(var width: Int = 0, var height: Int = 0, var cropType: CropType = CropType.CENTER) : BitmapTransformation() {
enum class CropType {
TOP, CENTER, BOTTOM
}

override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(TAG.toByteArray(CHARSET))
}

override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
width = if (width == 0) toTransform.width else width
height = if (height == 0) toTransform.height else height

val config = if (toTransform.config != null) toTransform.config else Bitmap.Config.ARGB_8888
val bitmap = pool[width, height, config]

bitmap.setHasAlpha(true)

val scaleX = width.toFloat() / toTransform.width
val scaleY = height.toFloat() / toTransform.height
val scale = max(scaleX, scaleY)

val scaledWidth = scale * toTransform.width
val scaledHeight = scale * toTransform.height
val left = (width - scaledWidth) / 2
val top: Float = getTop(scaledHeight)
val targetRect = RectF(left, top, left + scaledWidth, top + scaledHeight)

bitmap.density = toTransform.density
val canvas = Canvas(bitmap)
canvas.drawBitmap(toTransform, null, targetRect, null)

return bitmap
}

private fun getTop(scaledHeight: Float): Float {
return when (cropType) {
CropType.TOP -> 0f
CropType.CENTER -> (height - scaledHeight) / 2
CropType.BOTTOM -> height - scaledHeight
}
}

override fun equals(other: Any?): Boolean {
return other is CropTransformation && other.width == width && other.height == height && other.cropType == cropType
}

override fun hashCode(): Int {
return TAG.hashCode()
}
}


最近工作忙项目比较急,很少有时间更新blog了,不过好习惯还是应该保持,时间挤挤还是有的。项目中遇到一个需求,实现一个拉环动画,有一根拉伸可以往下拉动并在放手的时候回弹然后上下抖动,就跟我们生活中拖拽一根带了拉环的橡皮筋效果是一样的。最终我是用属性动画去实现这个需求,感觉不算复杂效果也还行,在此记录实现过程,欢迎相互交流~


最终效果图gif:
image

自定义View

实现AnimatorUpdateListener和Animator.AnimatorListener接口

1
2
3
4
5
6
7
8
9
class RingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), AnimatorUpdateListener, Animator.AnimatorListener{
override fun onAnimationUpdate(animation: ValueAnimator) {}
override fun onAnimationRepeat(animation: Animator) {}
override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {}
}

初始化

初始化paint,animator,设置1秒后开始动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
init {
paint.color = resources.getColor(R.color.colorPrimary)
paint.isAntiAlias = true
postDelayed({ startAnimation() }, 1000)
}
private fun initAnimator(start: Int, end: Int) {
animator = ValueAnimator.ofInt(start, end)
animator?.duration = 1000
animator?.interpolator = accelerateInterpolator
animator?.addUpdateListener(this)
animator?.addListener(this)
}

private fun startAnimation() {
initAnimator(startY, endY)
isDown = true
animator?.start()
}

绘制

绘制拉环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private val bgRing = BitmapFactory.decodeResource(resources, R.mipmap.bg_ring)
// 拉环绘制尺寸
private val targetWidth = (bgRing.width * 1.4).toInt()
private val targetHeight = (bgRing.height * 1.4).toInt()
private val radius = targetWidth / 2
// 拉环坐标
private var xPos = 0
private var yPos = 0
private val srcRingRect = Rect(0, 0, bgRing.width, bgRing.height)
private val dstRingRect = Rect()

private fun drawRing(canvas: Canvas) {
dstRingRect.left = xPos
dstRingRect.top = yPos
dstRingRect.right = xPos + targetWidth
dstRingRect.bottom = yPos + targetHeight
canvas.drawBitmap(bgRing, srcRingRect, dstRingRect, paint)
}

绘制弹力绳

1
2
3
4
5
6
private val strokeWidth = 6f  // 绳子粗细
private var ratio = 0f // 弹性变化比例
private fun drawLine(canvas: Canvas) {
paint.strokeWidth = strokeWidth - 3 * ratio
canvas.drawLine((xPos + radius).toFloat(), 0f, (xPos + radius).toFloat(), (yPos + 2).toFloat(), paint)
}

重写onDraw

初始化上下轻微抖动的范围和最大拖拽长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 上下轻微抖动的范围
private var startY = 0
private var endY = 0
private var maxDragY = 0 // 最大拖拽长度
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (animator == null) {
startY = canvas.height / 2 - radius / 2
endY = canvas.height / 2
yPos = startY
maxDragY = endY + endY / 2
xPos = canvas.width / 2 - radius
}
drawLine(canvas)
drawRing(canvas)
}

下拉后自动回弹

利用加速插值器和减速插值器实现惯性向下运动,减速向上回弹,监听动画结束事件,更新运动方向标志位,下落时设置为加速插值器,回弹时设置为减速插值器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private var isDown = true
private var stopAnimate = false // 是否停止动画
private val accelerateInterpolator = AccelerateInterpolator()
private val decelerateInterpolator = DecelerateInterpolator()
override fun onAnimationEnd(animation: Animator) {
if (!stopAnimate) {
val vAnimation = animation as ValueAnimator
isDown = !isDown
if (isDown) {
vAnimation.setIntValues(startY, endY)
vAnimation.interpolator = accelerateInterpolator
} else {
vAnimation.setIntValues(endY, startY)
vAnimation.interpolator = decelerateInterpolator
}
vAnimation.start()
}
}

拖拽逻辑

重写onTouchEvent,监听ACTION_DOWN/ACTION_UP/ACTION_MOVE事件
手指落下时:判断当前触摸位置坐标是否在拉环矩形区域内,如果是则记录开始拖拽位置的Y坐标,停止当前动画,记录上一次手指移动的Y坐标
手指移动时:记录手指当前Y坐标,如果是拖拽状态且拖拽距离大于10,计算当前拖拽的插值,如果插值大于0则为向下拖拽,继续判断当前Y坐标是否小于最大拖拽Y坐标位置,如果是计算拉环当前运动的目标位置,计算当前拖拽位置到最大拖拽位置的百分比,修正拉环的Y坐标,实现拖拽时移动缓慢的阻尼效果,达到最大拖拽位置时百分比为1,更新拖拽状态为false,刷新视图,更新最后一次拖拽的Y坐标
手指抬起时:更新拖拽状态为false,更新最后一次拖拽的Y坐标为0,触发回弹动画,设置为减速插值器,刷新动画标志位,向下标志位为false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
isDrag = dstRingRect.plus(padding).contains(event.x.toInt(), event.y.toInt())
if (isDrag) {
lastDragY = event.y
if (animator != null && animator!!.isRunning) {
stopAnimate = true
animator?.cancel()
}
lastMoveY = (yPos + radius).toFloat()
}
}
MotionEvent.ACTION_UP -> {
// 弹回去
back()
lastMoveY = 0f
isDrag = false
}
MotionEvent.ACTION_MOVE -> {
curDragY = event.y
if (isDrag && curDragY - lastDragY > 10) {
val diffY = curDragY - lastMoveY
Log.i(TAG, "diffY: $diffY" )
if (diffY > 0){
if (curDragY < maxDragY){
val targetY = curDragY.toInt()
Log.i(TAG, "maxLength: ${maxDragY - lastDragY}" )
ratio = (targetY - lastDragY) / (maxDragY - lastDragY)
yPos = (yPos + diffY * (1 - ratio)).toInt()
Log.i(TAG, "add: ${diffY * (1 - ratio)}, diffY:${diffY} ratio:${(1 - ratio)}" )
} else {
ratio = 1f
isDrag = false
}

invalidate()
}
lastMoveY = curDragY
}
}
}
return true
}

private fun back() {
if (animator != null && !animator!!.isRunning) {
animator?.setIntValues(yPos, startY)
animator?.interpolator = decelerateInterpolator
stopAnimate = false
animator?.start()
isDown = false
}
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
class RingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), AnimatorUpdateListener, Animator.AnimatorListener {
private val bgRing = BitmapFactory.decodeResource(resources, R.mipmap.bg_ring)
// 拉环绘制尺寸
private val targetWidth = (bgRing.width * 1.4).toInt()
private val targetHeight = (bgRing.height * 1.4).toInt()
private val radius = targetWidth / 2
// 拉环坐标
private var xPos = 0
private var yPos = 0
private val strokeWidth = 6f // 绳子粗细
private val paint = Paint()
private var animator: ValueAnimator? = null
private var isDown = true

// 上下轻微抖动的范围
private var startY = 0
private var endY = 0
private var maxDragY = 0// 最大拖拽长度
private val accelerateInterpolator = AccelerateInterpolator()
private val decelerateInterpolator = DecelerateInterpolator()
private val srcRingRect = Rect(0, 0, bgRing.width, bgRing.height)
private val dstRingRect = Rect()
private var isDrag = false// 是否拖拽
private var curDragY = 0f // 当前拖拽Y坐标
private var lastDragY = 0f // 开始拖拽Y坐标
private var stopAnimate = false // 是否停止动画
private var lastMoveY = 0f // 上一次手指移动Y坐标
private var ratio = 0f // 弹性变化比例
private var padding = 20 // 触摸范围增量
init {
paint.color = resources.getColor(R.color.colorPrimary)
paint.isAntiAlias = true
postDelayed({ startAnimation() }, 1000)
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (animator == null) {
startY = canvas.height / 2 - radius / 2
endY = canvas.height / 2
yPos = startY
maxDragY = endY + endY / 2
xPos = canvas.width / 2 - radius
}
drawLine(canvas)
drawRing(canvas)
}

private fun drawRing(canvas: Canvas) {
dstRingRect.left = xPos
dstRingRect.top = yPos
dstRingRect.right = xPos + targetWidth
dstRingRect.bottom = yPos + targetHeight
canvas.drawBitmap(bgRing, srcRingRect, dstRingRect, paint)
}

override fun onAnimationUpdate(animation: ValueAnimator) {
yPos = animation.animatedValue as Int
ratio = (yPos - startY).toFloat() / (maxDragY - startY)
invalidate()
}

private fun drawLine(canvas: Canvas) {
paint.strokeWidth = strokeWidth - 3 * ratio
canvas.drawLine((xPos + radius).toFloat(), 0f, (xPos + radius).toFloat(), (yPos + 2).toFloat(), paint)
}

private fun initAnimator(start: Int, end: Int) {
animator = ValueAnimator.ofInt(start, end)
animator?.duration = 1000
animator?.interpolator = accelerateInterpolator
animator?.addUpdateListener(this)
animator?.addListener(this)
}

private fun startAnimation() {
initAnimator(startY, endY)
isDown = true
animator?.start()
}

override fun onAnimationRepeat(animation: Animator) {}
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
if (!stopAnimate) {
val vAnimation = animation as ValueAnimator
isDown = !isDown
if (isDown) {
vAnimation.setIntValues(startY, endY)
vAnimation.interpolator = accelerateInterpolator
} else {
vAnimation.setIntValues(endY, startY)
vAnimation.interpolator = decelerateInterpolator
}
vAnimation.start()
}
}

override fun onAnimationCancel(animation: Animator) {}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
isDrag = dstRingRect.plus(padding).contains(event.x.toInt(), event.y.toInt())
if (isDrag) {
lastDragY = event.y
if (animator != null && animator!!.isRunning) {
stopAnimate = true
animator?.cancel()
}
lastMoveY = (yPos + radius).toFloat()
}
}
MotionEvent.ACTION_UP -> {
// 弹回去
back()
lastMoveY = 0f
isDrag = false
}
MotionEvent.ACTION_MOVE -> {
curDragY = event.y
if (isDrag && curDragY - lastDragY > 10) {
val diffY = curDragY - lastMoveY
Log.i(TAG, "diffY: $diffY" )
if (diffY > 0){
if (curDragY < maxDragY){
val targetY = curDragY.toInt()
Log.i(TAG, "maxLength: ${maxDragY - lastDragY}" )
ratio = (targetY - lastDragY) / (maxDragY - lastDragY)
yPos = (yPos + diffY * (1 - ratio)).toInt()
Log.i(TAG, "add: ${diffY * (1 - ratio)}, diffY:${diffY} ratio:${(1 - ratio)}" )
} else {
ratio = 1f
isDrag = false
}

invalidate()
}
lastMoveY = curDragY
}
}
}
return true
}

private fun back() {
if (animator != null && !animator!!.isRunning) {
animator?.setIntValues(yPos, startY)
animator?.interpolator = decelerateInterpolator
stopAnimate = false
animator?.start()
isDown = false
}
}

}

xml中引入

1
2
3
4
<com.example.testapp.RingView
android:layout_width="match_parent"
android:layout_height="match_parent"
/>


PPTP协议在没有配置智能分流的情况下,上网需要来回切换略显麻烦,有没有什么办法可以和ssr客户端和V2ray客户端那样实现分流呢?解决这个问题以后又会发现一个新的问题PPTP用的是google的dns,解析到的ip地址未必是适合的ip导致某些网站访问速度很慢,如果一个账号能让任意设备共享网络,省去折腾各个终端安装和配置那就太好了,最近利用家里的树莓派实现了这个需求,下面是我的折腾记录,有更好的方法欢迎留言交流~


安装PPTP的linux客户端

  1. apt-get安装pptp-linux
    sudo apt-get install pptp-linux

  2. 修改pptp-linux的配置文件

    sudo vi /etc/ppp/peers/pptpconf

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    pty "你的服务端地址 --nolaunchpppd"
    name 账号
    password 密码
    remotename PPTP
    require-mppe-128
    require-mschap-v2
    refuse-eap
    refuse-pap
    refuse-chap
    refuse-mschap
    noauth
    persist
    maxfail 0
    defaultroute
    replacedefaultroute
    usepeerdns
  3. 启动/关闭PPTP
    sudo pon pptpconf
    开启后,如果连接正常,ifconfig可以看到PPTP的连接ppp0

  4. 设置开机启动服务 sudo vi /lib/systemd/system/pptp.service

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [Unit]
    Description=PPTP Service
    After=network.target

    [Service]
    Type=forking
    ExecStart=/usr/bin/pon pptpconf

    [Install]
    WantedBy=multi-user.target
  5. 刷新并启动服务

    1
    2
    3
    sudo systemctl daemon-reload
    sudo systemctl enable pptp
    sudo systemctl start pptp

    chnroutes分流策略

    根据请求ip分流,某些网站强制转发到ppp0使用PPTP访问,某些网站不使用,这里记录PPTP下的使用方法,其它协议的使用方法可以查看官网:https://github.com/fivesheep/chnroutes

1
2
3
git clone https://github.com/fivesheep/chnroutes.git
cd chnroutes
sudo python chnroutes.py -p linux; sudo chmod a+x ip-pre-up; sudo cp ip-pre-up /etc/ppp; sudo chmod a+x ip-down; sudo cp ip-down /etc/ppp/ip-down.d/

clash开启http/https/socks5代理

  1. 安装clash
  • 下载安装包

    sudo wget https://github.com/Dreamacro/clash/releases/download/v1.6.5/clash-linux-armv7-v1.6.5.gz

  • 解压

    sudo gunzip clash-linux-armv7-v1.6.5.gz

  • 移动到系统目录

    sudo mv clash-linux-armv7-v1.6.5 /usr/local/bin/clash

  • 设置可执行权限

    sudo chmod +x /usr/local/bin/clash

  1. 设置配置文件
    sudo vi ~/.config/clash/config.yaml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    # port of HTTP
    port: 7890

    ## port of SOCKS5
    socks-port: 7891

    # `allow-lan` must be true in your config.yml
    allow-lan: true

    # set log level to stdout (default is info)
    # info / warning / error / debug / silent
    log-level: info

    # A RESTful API for clash
    #使用0.0.0.0可以使用局域网设备访问
    external-controller: 0.0.0.0:8080

    mode: Rule

    sudo clash启动,这样我们就拥有http/https/socks5的代理服务器了,任意设备只要配置树莓派的ip地址和对应协议的端口号即可代理请求,如果有公网ip或内网穿透,有域名+ddns解析服务器那么在外网也可以代理

  2. 设置开机启动服务

    sudo vi /etc/systemd/system/clash.service

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [Unit]
    Description=Clash Service
    After=network.target

    [Service]
    Restart=on-abort
    LimitNOFILE=1048576
    ExecStart=/usr/local/bin/clash -d /home/pi/.config/clash

    [Install]
    WantedBy=multi-user.target
  3. 转发所有请求 sudo vi /etc/sysctl.conf

  • ipv4的请求,修改

    net.ipv4.ip_forward=1

  • ipv6的请求,修改

    net.ipv6.conf.all.forwarding = 1
    刷新设置

    sudo sysctl -p
    流量强制转发到ppp0
    sudo iptables -t nat -A POSTROUTING -o ppp0 -j MASQUERADE

    dnsmasq + dnsmasq-china-list 本地DNS分流策略

  1. apt-get安装dnsmasq

    sudo apt install dnsmasq

  2. 设置配置文件

    sudo vi /etc/dnsmasq.conf

    1
    2
    3
    no-resolv
    server=8.8.8.8
    server=8.8.4.4
  3. 用dnsmasq-china-list设置白名单,用运营商dns去解析,其它用google的dns解析,否则解析到的ip并不是访问速度快的最适合的ip,导致网站和App的访问速度太慢了,所以太干净的dns解析也不好

  • 运营商分配的DNS地址假设为223.5.5.5,切换到su用户,拉取不需要走PPTP的地址用运营商分配的DNS地址去解析
    1
    curl -s https://raw.githubusercontent.com/felixonmars/dnsmasq-china-list/master/accelerated-domains.china.conf|sed 's/114.114.114.114/223.5.5.5/g' >/etc/dnsmasq.d/accelerated-domains.china.223.5.5.5.conf
  1. 重启dnsmasq

    service dnsmasq restart

  2. 测试分流效果

  • 未使用本地dns解析
    dig google.com @223.5.5.5
  • 使用本地dns解析
    dig google.com @127.0.0.1
  1. 添加自定义域名到白名单
    echo 'server=/你需要的域名/223.5.5.5' >>/etc/dnsmasq.d/accelerated-domains.china.223.5.5.5.conf

    DDNS动态域名解析

    如果家里是公网IP,可以使用DDNS给域名绑定动态ip,no-ip是ddns解析服务,免费赠送域名无需备案,速度还不错,每个月需要点一次邮件续期,官网的ip更新方式在树莓派下更新失败,折腾一段时间后,发现用ddclient可以正常更新,但是注意如果当前状态是pptp开启的情况下,获取到的外网ip可能不是运营商的公网ip,在配置了智能路由的情况下需使用无需PPTP访问的网站获取外网ip
    1
    sudo apt-get install ddclient
    设置配置文件 /etc/ddclient.conf
    1
    2
    3
    4
    5
    6
    protocol=noip
    use=web, web=获取外网ip的网站
    server=dynupdate.no-ip.com
    login=用户名(邮箱)
    password='密码'
    你的域名