天气app开发问题汇总

224 阅读5分钟

开发问题汇总

此贴用于记录柠檬天气开发过程中遇见的问题和解决方式,不定期更新

天气动画的性能问题

最初版本的天气动画View完全是用View进行绘制的,使用View的绘制在计算量较小的动画下比如晴天类型(旋转画布,绘制多个不同角度矩形,然后根据动画进度给一个旋转量)表现尚可,但涉及到复杂动画,雨天需要绘制成百个水滴/雪粒子,每次绘制结束后根据这个粒子的速度属性要计算下一帧的粒子的坐标,页面滑动时候会感觉页面明显的卡顿。

  • 第一次重构:

使用surfaceView进行重构了这个View,用独立线程的进行渲染,不在影响主线程。 期间遇见的小问题,surfaceView黑屏,android:background不生效,动画闪烁,等小问题皆可搜到直接解,不多赘述。

  • 第二次主要变动:

其他的页面滑动不再卡顿了,但是雨天动画的粒子数量约30左右时,View本身的动画依旧卡顿,试了一下同屏加入多个天气View,发现卡顿加剧,甚至页面滑动也卡顿了起来,这时候我分析是渲染,计算过于频繁导致cpu资源紧张,于是及记录下渲染计算耗时x,然后渲染时间结束后,线程休眠 (16ms - x)ms,将动画的帧率锁在60帧,基本解决了这个问题。

    syncThread = Thread {
      while (isWork) {
        // 记录上次执行渲染时间
        lastSyncTime = System.currentTimeMillis()
        // 执行渲染
        doOnDraw()
        // 保证两张帧之间间隔16ms(60帧)
        try {
          Thread.sleep(Math.max(0, 16 - (System.currentTimeMillis() - lastSyncTime)))
        } catch (_: Exception) {

        }

      }
    }
  • 进一步优化

去社区进一步查看了使用surfaceView作为关键字,查看了其他开源项目对于surfaceView的使用姿势,从中了解了计算和渲染进行分离的优化方式,用一个线程池,定时16ms对雨滴对象进行坐标的下一帧坐标运算,渲染线程只用于canvas绘制,大大缩短了渲染的耗时。

初次之外进行了其他的优化,如离开屏幕后停止对于的渲染任务和计算任务,从而降低对于算力的消耗。

各个功能卡片的统一管理

天气页面有各色功能的卡片,这些卡片拥有对于的不同功能,这些卡片还拥有对于的显示动画,各色功能,且要即时响应设置的变化,增加/删除 对于功能的卡片。

  • 第一个实现方式遇见问题

使用的是死布局,即是滑动布局嵌套线性布局,用一套逻辑开控制显示和隐藏。

遇见的问题:

  1. 不方便拓展,添加一个功能卡片就要写一套逻辑

  2. 难以实现排序操作和相关动画

  3. 显示动画相关逻辑容易粘连滑动布局和view,且各个布局逻辑类似,每个重新写一份代码及其冗余。

  • 改进的方案

    使用RecycleView改造,抽象出一个WeatherCard类,既是天气卡片类,这个卡片需要有如下属性

    enterAnim:入场动画对象,有个默认实现,卡片可以根据需求进行更改&拓展

    startEnterAnim( ) :进行入场动画,将View从透明到不透明,并添加浮动等效果,可拓展

    resetAnim( ) : 重置动画,默认实现是将View透明化

    checkEnterScreen(): 检查这张卡片是否进入显示区域,默认实现是进入1/3,播放入场动画

    RecycleView层面,抽象出AbstractMainViewHolder,实现如下:

    open class AbstractMainViewHolder(val card: SpicaWeatherCard, itemView: View) :
        RecyclerView.ViewHolder(itemView) {
    
    
        fun bindView(weather: Weather) {
            card.bindData(weather)
        }
    
        fun reset(){
            card.resetAnim()
        }
    
        private val rect = Rect()
        fun checkEnterScreen() {
            try {
                val isVisible = itemView.getGlobalVisibleRect(rect)
                card.checkEnterScreen(isVisible && rect.bottom - rect.top >= itemView.height / 10f)
            } catch (e: Exception) {
                e.message
            }
    
        }
    
    }
    

    在外部RecycleView进行滑动或者View被创建的时候(可能有些View在开头,被创建的时候本身就在屏幕之内)的时候,对每个ViewHolder进行显示检测,对于第一次入场的View播放入场动画。

      fun onScroll() {
        var holder: AbstractMainViewHolder
        if (itemCount == 0) return
        for (i in 0 until itemCount) {
          if (recyclerView.findViewHolderForAdapterPosition(i) != null) {
            holder = recyclerView.findViewHolderForAdapterPosition(i) as AbstractMainViewHolder
            holder.checkEnterScreen()
          }
        }
      }
    

    在卡片的管理方面,列了一个枚举,列出卡片的类型,这个枚举也是RecyclerView的数据单元,onCreateViewHolder()阶段时,根据枚举类型,创建对应的卡片实现,然后放入AbstractMainViewHolder中返回。这样子,卡片的排序,动画都可以通过Adapter和RecyclerView主动去控制,后续添加新的卡片,也只需要多写一个天气卡片的实现类,一个新枚举,onCreateViewHolder添加一个if逻辑,入场动画判断,卡片的显示隐藏管理不需要重复再写一遍了。

折线图和RecyclerView的焦点问题

折线图我截至到写下的版本是用RecyclerView实现的,正在使用纯View重新实现,但处理方式基本一致,问题处理是手指从折线图区域滑动,如何判断用户滑动的焦点是横向的折线图还是竖向的RecyclerView。

先获取滑动触发的阈值

private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop

然后滑动过程中根据触摸焦点x和y的变化率来判断是把事件交由父级处理,还是自己消费。

核心逻辑如下

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        when (ev.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                mBeingDragged = false
                mHorizontalDragged = false
                mPointerId = ev.getPointerId(0)
                mInitialX = ev.x
                mInitialY = ev.y
                parent.requestDisallowInterceptTouchEvent(true)
            }
            MotionEvent.ACTION_POINTER_DOWN -> {
                val index: Int = ev.actionIndex
                mPointerId = ev.getPointerId(index)
                mInitialX = ev.getX(index)
                mInitialY = ev.getY(index)
            }
            MotionEvent.ACTION_MOVE -> {
                val index: Int = ev.findPointerIndex(mPointerId)
                if (index != -1) {
                    val x: Float = ev.getX(index)
                    val y: Float = ev.getY(index)
                    if (!mBeingDragged && !mHorizontalDragged) {
                        if (abs(x - mInitialX) > mTouchSlop || abs(y - mInitialY) > mTouchSlop) {
                            mBeingDragged = true
                            if (Math.abs(x - mInitialX) > abs(y - mInitialY)) {
                                mHorizontalDragged = true
                            } else {
                                parent.requestDisallowInterceptTouchEvent(false)
                            }
                        }
                    }
                }

            }
            MotionEvent.ACTION_POINTER_UP -> {
                val index: Int = ev.actionIndex
                val id: Int = ev.getPointerId(index)
                if (mPointerId == id) {
                    val newIndex = if (index == 0) 1 else 0
                    mPointerId = ev.getPointerId(newIndex)
                    mInitialX = ev.getX(newIndex)
                    mInitialY = ev.getY(newIndex)
                }
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                mBeingDragged = false
                mHorizontalDragged = false
                parent.requestDisallowInterceptTouchEvent(false)
            }
        }

        return super.onInterceptTouchEvent(ev) && mBeingDragged && mHorizontalDragged
    }