Android 弹幕的两种实现及性能对比 | 自定义 LayoutManager

简介: Android 弹幕的两种实现及性能对比 | 自定义 LayoutManager

引子


上一篇用“动画”方案实现了弹幕效果,自定义容器控件,每一条弹幕都作为其子控件,将子弹幕的初始位置置于容器控件右边的外侧,每条弹幕都通过从右向左的动画来实现贯穿屏幕的平移。


这个方案的性能有待改善,打开 GPU 呈现模式:


image.png


https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fde30e697c464385a09f1cf03d35762a~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp


原因在于容器控件会提前构建所有弹幕视图并将它们堆积在屏幕的右侧。若弹幕数据量大,则容器控件会因为子视图过多而耗费大量 measure + layout 时间。


既然是因为提前加载了不需要的弹幕才导致的性能问题,那是不是可以只预加载有限个弹幕?


只加载有限个子视图且可滚动的控件,不就是 RecyclerView 吗!它并不会把 Adapter 中所有的数据提前全部转换成 View,而是只预加载一屏的数据,然后随着滚动再持续不断地加载新数据。


为了用 RecyclerView 实现弹幕效果,就得 “自定义 LayoutManager”。


自定义布局参数


自定义 LayoutManager 的第一步:继承RecyclerView.LayoutManger


class LaneLayoutManager: RecyclerView.LayoutManager() {
    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}
}


根据 AndroidStudio 的提示,必须实现一个generateDefaultLayoutParams()的方法。它用于生成一个自定义的LayoutParams对象,目的是在布局参数中携带自定义的属性。


当前场景中没有自定义布局参数的需求,遂可以这样实现这个方法:


override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
    return RecyclerView.LayoutParams(
        RecyclerView.LayoutParams.WRAP_CONTENT,
        RecyclerView.LayoutParams.WRAP_CONTENT
    )
}


表示沿用RecyclerView.LayoutParams


初次填充弹幕


自定义 LayoutManager 最重要的环节就是定义如何布局表项。


对于LinearLayoutManager来说,表项沿着一个方向线性铺开。当列表第一次展示时,从列表顶部到底部,表项被逐个填充,这称为“初次填充”。


对于LaneLayoutManager来说,初次填充即是“将一列弹幕填充到紧挨着列表尾部的地方(在屏幕之外,可不见)”。


关于LinearLayoutManager如何填充表项的源码分析,在之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?中分析过,现援引结论如下:


  1. LinearLayoutManager 在onLayoutChildren()方法中布局表项。


  1. 布局表项的关键方法包括fill()layoutChunk(),前者表示列表的一次填充动作,后者表示填充单个表项。


  1. 在一次填充动作中通过一个while循环不断地填充表项,直到列表剩余空间用完。用伪代码表示这个过程如下所示:


public class LinearLayoutManager {
   // 布局表项
   public void onLayoutChildren() {
       // 填充表项
       fill() {
           while(列表有剩余空间){
               // 填充单个表项
               layoutChunk(){
                   // 让表项成为子视图
                   addView(view)
               }
           }
       }
   }
}


  1. 为了避免每次填充新表项时都重新创建视图,需要从 RecyclerView 的缓存中获取表项视图,即调用Recycler.getViewForPosition()。关于该方法的详解可以点击RecyclerView 缓存机制 | 如何复用表项?


看过源码,理解原理后,弹幕布局就可以仿照着写:


class LaneLayoutManager : RecyclerView.LayoutManager() {
    private val LAYOUT_FINISH = -1 // 标记填充结束
    private var adapterIndex = 0 // 列表适配器索引
    // 弹幕纵向间距
    var gap = 5
        get() = field.dp
    // 布局孩子
    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
        fill(recycler)
    }
    // 填充表项
    private fun fill(recycler: RecyclerView.Recycler?) {
        // 可供弹幕布局的高度,即列表高度
        var totalSpace = height - paddingTop - paddingBottom
        var remainSpace = totalSpace
        // 只要空间足够,就继续填充表项
        while (goOnLayout(remainSpace)) {
            // 填充单个表项
            val consumeSpace = layoutView(recycler)
            if (consumeSpace == LAYOUT_FINISH) break
            // 更新剩余空间
            remainSpace -= consumeSpace
        }
    }
    // 是否还有剩余空间用于填充 以及 是否有更多数据
    private fun goOnLayout(remainSpace: Int) = remainSpace > 0 && adapterIndex in 0 until itemCount
    // 填充单个表项
    private fun layoutView(recycler: RecyclerView.Recycler?): Int {
        // 1. 从缓存池中获取表项视图
        // 若缓存未命中,则会触发 onCreateViewHolder() 和 onBindViewHolder()
        val view = recycler?.getViewForPosition(adapterIndex)
        view ?: return LAYOUT_FINISH // 获取表项视图失败,则结束填充
        // 2. 将表项视图成为列表孩子
        addView(view)
        // 3. 测量表项视图
        measureChildWithMargins(view, 0, 0)
        // 可供弹幕布局的高度,即列表高度
        var totalSpace = height - paddingTop - paddingBottom
        // 弹幕泳道数,即列表纵向可以容纳几条弹幕
        val laneCount = (totalSpace + gap) / (view.measuredHeight + gap)
        // 计算当前表项所在泳道
        val index = adapterIndex % laneCount
        // 计算当前表项上下左右边框
        val left = width // 弹幕左边位于列表右边
        val top = index * (view.measuredHeight + gap)
        val right = left + view.measuredWidth
        val bottom = top + view.measuredHeight
        // 4. 布局表项(该方法考虑到了 ItemDecoration)
        layoutDecorated(view, left, top, right, bottom)
        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
        // 继续获取下一个表项视图
        adapterIndex++
        // 返回填充表项消耗像素值
        return getDecoratedMeasuredHeight(view) + verticalMargin
    }
}


每一条水平的,供弹幕滚动的,称之为“泳道”。


泳道是从列表顶部往底部垂直铺开的,列表高度/泳道高度 = 泳道的数量。


fill()方法中就以“列表剩余高度>0”为循环条件,不断地向泳道中填充表项,它得经历了四个步骤:


  1. 从缓存池中获取表项视图


  1. 将表项视图成为列表孩子


  1. 测量表项视图


  1. 布局表项 这四步之后,表项相对于列表的位置就确定下来,并且表项的视图已经渲染完成。


运行下 demo,果然~,什么也没看到。。。


列表滚动逻辑还未加上,所以布局在列表右边外侧的表项依然处于不可见位置。但可以利用 AndroidStudio 的Layout Inspector工具来验证初次填充代码的正确性:


image.png


Layout Inspector中会用线框表示屏幕以外的控件,如图所示,列表右边的外侧被四个表项占满。


自动滚动弹幕


为了看到填充的表项,就得让列表自发地滚动起来。


最直接的方案就是不停地调用RecyclerView.smoothScrollBy()。为此写了一个扩展法方法用于倒计时:


fun <T> countdown(
    duration: Long, // 倒计时总时长
    interval: Long, // 倒计时间隔
    onCountdown: suspend (Long) -> T // 倒计时回调
): Flow<T> =
    flow { (duration - interval downTo 0 step interval).forEach { emit(it) } }
        .onEach { delay(interval) }
        .onStart { emit(duration) }
        .map { onCountdown(it) }
        .flowOn(Dispatchers.Default)


使用Flow构建了一个异步数据流,该流每次都会发射一个倒计时的剩余时间。关于Flow的详细解释可以点击Kotlin 异步 | Flow 应用场景及原理


然后就能像这样实现列表自动滚动:


countdown(Long.MAX_VALUE, 50) {
    recyclerView.smoothScrollBy(10, 0)
}.launchIn(MainScope())


每 50 ms 向左滚动 10 像素。效果如下图所示:


image.png


https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f8e9542148f64c48b6a8fbe8e804e90a~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?


持续填充弹幕


因为只做了初次填充,即每个泳道只填充了一个表项,所以随着第一排的表项滚入屏幕后,就没有后续弹幕了。


LayoutManger.onLayoutChildren()只会在列表初次布局时调用一次,即初次填充弹幕只会执行一次。为了持续不断地展示弹幕,必须在滚动时不停地填充表项。


之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?分析过列表滚动时持续填充表项的源码,现援引结论如下:


  1. RecyclerView 在滚动发生之前,会根据预计滚动位移大小来决定需要向列表中填充多少新的表项。


  1. 表现在源码上,即是在scrollVerticallyBy()中调用fill()填充表项:


public class LinearLayoutManager {
   @Override
   public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
       return scrollBy(dy, recycler, state);
   }
   int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
       ...
       // 填充表项
       final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
       ...
   }
}


对于弹幕的场景,也可以仿照着写一个类似的:


class LaneLayoutManager : RecyclerView.LayoutManager() {
    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
        return scrollBy(dx, recycler) 
    }
    override fun canScrollHorizontally(): Boolean {
        return true // 表示列表可以横向滚动
    }
}


重写canScrollHorizontally()返回 true 表示列表可横向滚动。


RecyclerView 的滚动是一段一段进行的,每一段滚动的位移都会通过scrollHorizontallyBy()传递过来。通常在该方法中根据位移大小填充新的表项,然后再触发列表的滚动。关于列表滚动的源码分析可以点击RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势


scrollBy()封装了根据滚动持续填充表项的逻辑。(稍后分析)


持续填充表项比初次填充的逻辑更复杂一点,初次填充只要将表项按照泳道从上到下依次铺开填满列表的高度即可。而持续填充得根据滚动距离计算出哪个泳道即将枯竭(没有弹幕展示的泳道),只对枯竭的泳道填充表项。


为了快速获取枯竭泳道,得抽象出一个“泳道”结构,以保存该泳道的滚动信息:


// 泳道
data class Lane(
    var end: Int, // 泳道末尾弹幕横坐标
    var endLayoutIndex: Int, // 泳道末尾弹幕的布局索引
    var startLayoutIndex: Int // 泳道头部弹幕的布局索引
)


泳道结构包含三个数据,分别是:


  1. 泳道末尾弹幕横坐标:它是泳道中最后一个弹幕的 right 值,即它的右侧相对于 RecyclerView 左侧的距离。该值用于判断经过一段位移的滚动后,该泳道是否会枯竭。


  1. 泳道末尾弹幕的布局索引:它是泳道中最后一个弹幕的布局索引,记录它是为了方便地通过getChildAt()获取泳道中最后一个弹幕的视图。(布局索引有别于适配器索引,RecyclerView 只会持有有限个表项,所以布局索引的取值范围是[0,x],x的取值比一屏表项稍多一点,而对于弹幕来说,适配器索引的取值是[0,∞])


  1. 泳道头部弹幕的布局索引:与 2 类似,为了方便地获得泳道第一个弹幕的视图。


借助于泳道这个结构,我们得重构下初次填充表项的逻辑:


class LaneLayoutManager : RecyclerView.LayoutManager() {
    // 初次填充过程中的上一个被填充的弹幕
    private var lastLaneEndView: View? = null
    // 所有泳道
    private var lanes = mutableListOf<Lane>()
    // 初次填充弹幕
    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
        fillLanes(recycler, lanes)
    }
    // 通过循环填充弹幕
    private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {
        lastLaneEndView = null
        // 如果列表垂直方向上还有空间则继续填充弹幕
        while (hasMoreLane(height - lanes.bottom())) {
            // 填充单个弹幕到泳道中
            val consumeSpace = layoutView(recycler, lanes)
            if (consumeSpace == LAYOUT_FINISH) break
        }
    }
    // 填充单个弹幕,并记录泳道信息
    private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {
        val view = recycler?.getViewForPosition(adapterIndex)
        view ?: return LAYOUT_FINISH
        measureChildWithMargins(view, 0, 0)
        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
        val consumed = getDecoratedMeasuredHeight(view) + if (lastLaneEndView == null) 0 else verticalGap + verticalMargin
        // 若列表垂直方向还可以容纳一条新得泳道,则新建泳道,否则停止填充
        if (height - lanes.bottom() - consumed > 0) {
            lanes.add(emptyLane(adapterIndex))
        } else return LAYOUT_FINISH
        addView(view)
        // 获取最新追加的泳道
        val lane = lanes.last()
        // 计算弹幕上下左右的边框
        val left = lane.end + horizontalGap
        val top = if (lastLaneEndView == null) paddingTop else lastLaneEndView!!.bottom + verticalGap
        val right = left + view.measuredWidth
        val bottom = top + view.measuredHeight
        // 定位弹幕
        layoutDecorated(view, left, top, right, bottom)
        // 更新泳道末尾横坐标及布局索引
        lane.apply {
            end = right
            endLayoutIndex = childCount - 1 // 因为是刚追加的表项,所以其索引值必然是最大的
        }
        adapterIndex++
        lastLaneEndView = view
        return consumed
    }
}


初次填充弹幕也是一个不断在垂直方向上追加泳道的过程,判断是否追加的逻辑如下:列表高度 - 当前最底部泳道的 bottom 值 - 这次填充弹幕消耗的像素值 > 0,其中lanes.bottom()是一个List的扩展方法:


fun List<Lane>.bottom() = lastOrNull()?.getEndView()?.bottom ?: 0


它获取泳道列表中的最后一个泳道,然后再获取该泳道中最后一条弹幕视图的 bottom 值。其中getEndView()被定义为Lane的扩展方法:


class LaneLayoutManager : RecyclerView.LayoutManager() {
    data class Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)
    private fun Lane.getEndView(): View? = getChildAt(endLayoutIndex)
}


理论上“获取泳道中最后一条弹幕视图”应该是Lane提供的方法。但偏偏把它定义成Lane的扩展方法,并且还定义在LaneLayoutManager的内部,这是多此一举吗?


若定义在 Lane 内部,则在该上下文中无法访问到LayoutManager.getChildAt()方法,若只定义为LaneLayoutManager的私有方法,则无法访问到endLayoutIndex。所以此举是为了综合两个上下文环境。


再回头看一下滚动时持续填充弹幕的逻辑:


class LaneLayoutManager : RecyclerView.LayoutManager() {
    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
        return scrollBy(dx, recycler) 
    }
    // 根据位移大小决定填充多少表项
    private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
        // 若列表没有孩子或未发生滚动则返回
        if (childCount == 0 || dx == 0) return 0
        // 在滚动还未开始前,更新泳道信息
        updateLanesEnd(lanes)
        // 获取滚动绝对值
        val absDx = abs(dx) 
        // 遍历所有泳道,向其中的枯竭泳道填充弹幕
        lanes.forEach { lane ->
            if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
        }
        // 滚动列表的落脚点:将表项向手指位移的反方向平移相同的距离
        offsetChildrenHorizontal(-absDx)
        return dx
    }
}


滚动时持续填充弹幕逻辑遵循这样的顺序:


  1. 更新泳道信息


  1. 向枯竭泳道填充弹幕


  1. 触发滚动 其中 1,2 都发生在真实的滚动之前,在滚动之前,已经拿到了滚动位移,根据位移就可以计算出滚动发生之后即将枯竭的泳道:


// 泳道是否枯竭
private fun Lane.isDrainOut(dx: Int): Boolean = getEnd(getEndView()) - dx < width
// 获取表项的 right 值
private fun getEnd(view: View?) = 
    if (view == null) Int.MIN_VALUE 
    else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin


泳道枯竭的判定依据是:泳道最后一个弹幕的右边向左平移 dx 后是否小于列表宽度。若小于则表示泳道中的弹幕已经全展示完了,此时就要继续填充弹幕:


// 弹幕滚动时填充新弹幕
private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) {
    val view = recycler?.getViewForPosition(adapterIndex)
    view ?: return
    measureChildWithMargins(view, 0, 0)
    addView(view)
    val left = lane.end + horizontalGap
    val top = lane.getEndView()?.top ?: paddingTop
    val right = left + view.measuredWidth
    val bottom = top + view.measuredHeight
    layoutDecorated(view, left, top, right, bottom)
    lane.apply {
        end = right
        endLayoutIndex = childCount - 1
    }
    adapterIndex++
}


填充逻辑和初次填充的几乎一样,唯一的区别是,滚动时的填充不可能因为空间不够而提前返回,因为是找准了泳道进行填充的。


为什么要在填充枯竭泳道之前更新泳道信息?


// 更新泳道信息
private fun updateLanesEnd(lanes: MutableList<Lane>) {
    lanes.forEach { lane ->
        lane.getEndView()?.let { lane.end = getEnd(it) }
    }
}


因为 RecyclerView 的滚动是一段一段进行的,看似滚动了一丢丢距离,

scrollHorizontallyBy()可能要回调十几次,每一次回调,弹幕都会前进一小段,即泳道末尾弹幕的横坐标会发生变化,这变化得同步到Lane结构中。否则泳道枯竭的计算就会出错。


无限滚动弹幕


经过初次和持续填充,弹幕已经可以流畅的滚起来了。那如何让仅有的弹幕数据无限轮播呢?


只需要在Adapter上做一个小手脚:


class LaneAdapter : RecyclerView.Adapter<ViewHolder>() {
    // 数据集
    private val dataList = MutableList()
    override fun getItemCount(): Int {
        // 设置表项为无穷大
        return Int.MAX_VALUE
    }
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val realIndex = position % dataList.size
        ...
    }
    override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
        val realIndex = position % dataList.size
        ...
    }
}


设置列表的数据量为无穷大,当创建表项视图及为其绑定数据时,对适配器索引取模。

回收弹幕


剩下的最后一个难题是,如何回收弹幕。若没有回收,也对不起RecyclerView这个名字。


LayoutManager中就定义有回收表项的入口:


public void removeAndRecycleView(View child, @NonNull Recycler recycler) {
    removeView(child);
    recycler.recycleView(child);
}


回收逻辑最终会委托给Recycler实现,关于回收表项的源码分析,可以点击下面的文章:


  1. RecyclerView 缓存机制 | 回收些什么?


  1. RecyclerView 缓存机制 | 回收到哪去?


  1. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)


  1. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系


  1. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?


对于弹幕场景,什么时候回收弹幕?


当然是弹幕滚出屏幕的那一瞬间!


如何才能捕捉到这个瞬间 ?


当然是通过在每次滚动发生之前用位移计算出来的!


在滚动时除了要持续填充弹幕,还得持续回收弹幕(源码里就是这么写的,我只是抄袭一下):


private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
    if (childCount == 0 || dx == 0) return 0
    updateLanesEnd(lanes)
    val absDx = abs(dx)
    // 持续填充弹幕
    lanes.forEach { lane ->
        if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
    }
    // 持续回收弹幕
    recycleGoneView(lanes, absDx, recycler)
    offsetChildrenHorizontal(-absDx)
    return dx
}


这是scrollBy()的完整版,滚动时先填充,紧接着马上回收:


fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) {
    recycler ?: return
    // 遍历泳道
    lanes.forEach { lane ->
        // 获取泳道头部弹幕
        getChildAt(lane.startLayoutIndex)?.let { startView ->
            // 如果泳道头部弹幕已经滚出屏幕则回收它
            if (isGoneByScroll(startView, dx)) {
                // 回收弹幕视图
                removeAndRecycleView(startView, recycler)
                // 更新泳道信息
                updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
                lane.startLayoutIndex += lanes.size - 1
            }
        }
    }
}


回收和填充一样,也是通过遍历找到即将消失的弹幕,回收之。


判断弹幕消失的逻辑如下:


fun isGoneByScroll(view: View, dx: Int): Boolean = getEnd(view) - dx < 0


如果弹幕的 right 向左平移 dx 后小于 0 则表示弹幕已经滚出列表。


回收弹幕之后,会将其从 RecyclerView 中 detach,这个操作会影响列表中其他弹幕的布局索引值。就好像数组中某一元素被删除,其后面的所有元素的索引值都会减一:


fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) {
    lanes.forEach { lane ->
        if (lane.startLayoutIndex > recycleIndex) {
            lane.startLayoutIndex--
        }
        if (lane.endLayoutIndex > recycleIndex) {
            lane.endLayoutIndex--
        }
    }
}


遍历所有泳道,只要泳道头部弹幕的布局索引大于回收索引,则将其减一。


性能


再次打开 GPU 呈现模式:


image.png


https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cf622739293c481d9f194e2f4a7a18af~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?


这次体验上就很丝滑,柱状图也没有超过警戒线。


talk is cheap, show me the code


完整代码可以点击这里,在这个repo中搜索LaneLayoutManager


总结


之前花了很多时间看源码,也产生过“看源码那么费时,到底有什么用?”这样的怀疑。这次性能优化是一次很好的回应。因为看过 RecyclerView 的源码,它解决问题的思想方法就种在脑袋里了。当遇到弹幕性能问题时,这颗种子就会发芽。解决方案是多种多样的,脑袋中有怎样的种子,就会长出怎样的芽。所以看源码是播撒种子,虽不能立刻发芽,但总有一天会结果。


推荐阅读


  1. Android自定义控件 | View绘制原理(画多大?)


  1. Android自定义控件 | View绘制原理(画在哪?)


  1. Android自定义控件 | View绘制原理(画什么?)


  1. Android自定义控件 | 源码里有宝藏之自动换行控件


  1. Android自定义控件 | 小红点的三种实现(上)


  1. Android自定义控件 | 小红点的三种实现(下)


  1. Android自定义控件 | 小红点的三种实现(终结)


  1. Android 弹幕的两种实现及性能对比 | 自定义控件


  1. Android 弹幕的两种实现及性能对比 | 自定义 LayoutManager


目录
相关文章
|
21天前
|
缓存 前端开发 Android开发
安卓开发中的自定义视图:从零到英雄
【10月更文挑战第42天】 在安卓的世界里,自定义视图是一块画布,让开发者能够绘制出独一无二的界面体验。本文将带你走进自定义视图的大门,通过深入浅出的方式,让你从零基础到能够独立设计并实现复杂的自定义组件。我们将探索自定义视图的核心概念、实现步骤,以及如何优化你的视图以提高性能和兼容性。准备好了吗?让我们开始这段创造性的旅程吧!
22 1
|
2月前
|
Android开发 开发者
安卓应用开发中的自定义视图
【9月更文挑战第37天】在安卓开发的海洋中,自定义视图犹如一座座小岛,等待着勇敢的探索者去发现其独特之处。本文将带领你踏上这段旅程,从浅滩走向深海,逐步揭开自定义视图的神秘面纱。
42 3
|
2月前
|
数据可视化 Android开发 开发者
安卓应用开发中的自定义View组件
【10月更文挑战第5天】在安卓应用开发中,自定义View组件是提升用户交互体验的利器。本篇将深入探讨如何从零开始创建自定义View,包括设计理念、实现步骤以及性能优化技巧,帮助开发者打造流畅且富有创意的用户界面。
93 0
|
4月前
|
移动开发 监控 前端开发
构建高效Android应用:从优化布局到提升性能
【7月更文挑战第60天】在移动开发领域,一个流畅且响应迅速的应用程序是用户留存的关键。针对Android平台,开发者面临的挑战包括多样化的设备兼容性和性能优化。本文将深入探讨如何通过改进布局设计、内存管理和多线程处理来构建高效的Android应用。我们将剖析布局优化的细节,并讨论最新的Android性能提升策略,以帮助开发者创建更快速、更流畅的用户体验。
69 10
|
4月前
|
供应链 物联网 区块链
未来触手可及:探索新兴技术的趋势与应用安卓开发中的自定义视图:从基础到进阶
【8月更文挑战第30天】随着科技的飞速发展,新兴技术如区块链、物联网和虚拟现实正在重塑我们的世界。本文将深入探讨这些技术的发展趋势和应用场景,带你领略未来的可能性。
|
4月前
|
测试技术 Android开发 Python
探索软件测试的艺术:从基础到高级安卓应用开发中的自定义视图
【8月更文挑战第29天】在软件开发的世界中,测试是不可或缺的一环。它如同艺术一般,需要精细的技巧和深厚的知识。本文旨在通过浅显易懂的语言,引领读者从软件测试的基础出发,逐步深入到更复杂的测试策略和工具的使用,最终达到能够独立进行高效测试的水平。我们将一起探索如何通过不同的测试方法来确保软件的质量和性能,就像艺术家通过不同的色彩和笔触来完成一幅画作一样。
|
1月前
|
搜索推荐 前端开发 Android开发
安卓应用开发中的自定义视图实现
【10月更文挑战第30天】在安卓开发的海洋中,自定义视图是那抹不可或缺的亮色,它为应用界面的个性化和交互体验的提升提供了无限可能。本文将深入探讨如何在安卓平台创建自定义视图,并展示如何通过代码实现这一过程。我们将从基础出发,逐步引导你理解自定义视图的核心概念,然后通过一个实际的代码示例,详细讲解如何将理论应用于实践,最终实现一个美观且具有良好用户体验的自定义控件。无论你是想提高自己的开发技能,还是仅仅出于对安卓开发的兴趣,这篇文章都将为你提供价值。
|
1月前
|
Android开发 开发者 UED
安卓开发中自定义View的实现与性能优化
【10月更文挑战第28天】在安卓开发领域,自定义View是提升应用界面独特性和用户体验的重要手段。本文将深入探讨如何高效地创建和管理自定义View,以及如何通过代码和性能调优来确保流畅的交互体验。我们将一起学习自定义View的生命周期、绘图基础和事件处理,进而探索内存和布局优化技巧,最终实现既美观又高效的安卓界面。
33 5
|
1月前
|
算法 JavaScript Android开发