从零开始Android商业项目Vibe coding完全指南(六)

20260610

读者应具备的知识基础

Kotlin、 Compose、 基础Android开发(谷歌官网课程优先)

给AI制订规则

Gemini经常犯病,我们可以通过添加规则,让它每次执行任务前读一遍,从而省略重复添加提示词的烦恼------其实也未必管用,我在实测中发现AI也经常无视规则:(

设置路径在 Settings -> AI -> Prompt Library

如图,我设置了两条规则。

自定义Compose以实现翻页动效切换、自动分页

关于AI编程能力的碎碎念

非常遗憾,对于这个功能Vibe coding没能起到什么作用,即便我输入了完整的解决方案给AI:(

对于AI编程能力,我认为可以效法图灵测试,进行如下分级:

  • Vibe coding级:无需技术术语,不懂编程的人通过自然语言描述需求,AI可以完成需求并输出质量达到商业级的产品

  • 技术支持级:在较为困难的领域,只有通过技术术语才能交流,能自主评估试错各种方案去突破其已知"参考资料",能在模型自身能力范围内完成代码,也能在人类工程师给出切实可行方案后完成突破自身极限能力的任务,并能够据此学习提升

  • 实习生级:只能够根据"参考资料"复刻特定功能,即便人类工程师给出切实可行方案,也可能没有办法实现

以我目前的观察,所有AI编程均在实习生级。

需求描述

翻页动效

在水平分页模式下,我们的app需要支持更多的翻页动效支持以提供更佳用户体验。

自动分页

自动分页功能十分重要。简单来说,就是无论漫画源是怎样的,总能够实现竖屏单页、横屏双页 。毫无疑问,这涉及到宽页拆分为二与双页合并为一两种场景,因此这会影响到多线程加载模型,比如说:当出现需要宽页拆分为二的场景时,下一页需要等待当前页处理完毕后,再拿到剩下的半页进行显示。

自动分页具体的实现方案有几种,但只要进行多线程加载,逻辑都相当繁杂不会简单。读者可以自行推演。如果不是非要像我们这样制作优质商业应用,建议先从单线程加载起步。

View体系下的实现

对于支持多种翻页动效,很容易想到,设计一个接口,然后派生不同的自定义View即可实现。

比如,一个最基础的水平分页滑动动效组件,我之前是这样实现的:

kotlin 复制代码
class SlidingPagesLayout : ViewGroup, IHorizontalPagesLayout {
    /**
     *保存当前中间页对应的scrollX值,是使用了scrollTo方法之后得到的
     * */
    private var middleScrollX = 0
    private var listener: TurnPageListener? = null
    private var animaTime = ANIMA_MILLISECOND
    private var readingDirectionLeft = false

    /**
     * 触发翻页动画的最小位移量
     * */
    private var minSlop = 0

    private var child0Initialized = false
    private var child2Initialized = false

    private var scroller: Scroller = Scroller(context, LinearInterpolator())

    constructor(context: Context) : this(context, null)
    constructor(context: Context, attr: AttributeSet?) : this(context, attr, 0)
    constructor(context: Context, attr: AttributeSet?, defStyleAttr: Int) : this(
        context,
        attr,
        defStyleAttr,
        0
    )

    constructor(context: Context, attr: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(
        context,
        attr,
        defStyleAttr,
        defStyleRes
    ) {
        inflateChildView(context)
    }

    private fun inflateChildView(context: Context) {
//        放3个imageView进去,作为左、中、右三页
        for (i in 0 until 3) {
            val child = LayoutInflater.from(context)
                .inflate(R.layout.horizontal_page_item_view, this, false)
//            自定义的LayoutParams
            val layoutParams = LayoutParams()
            when (i) {
                0 -> layoutParams.location = LOCATION_LEFT
                1 -> layoutParams.location = LOCATION_MIDDLE
                2 -> layoutParams.location = LOCATION_RIGHT
            }
            child.setLayoutParams(layoutParams)
            addView(child)
        }
    }

    override fun thisView(): View {
        return this
    }

    override fun setTurnPageListener(listener: TurnPageListener) {
        this.listener = listener
    }

    override fun setMinSlop(s: Int) {
        minSlop = s
    }

    override fun setAnimaTime(t: Int) {
        animaTime = t
    }

    override fun setReadingDirectionLeft(b: Boolean) {
        readingDirectionLeft = b
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
//        因为使用match_parent,所以沿用默认实现,不特殊处理实现wrap_content也没关系
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        measureChildren(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
//        采用FrameLayout中的实现修改得来
        layoutChildren(left, top, right, bottom)
    }

    private fun layoutChildren(
        left: Int,
        top: Int,
        right: Int,
        bottom: Int,
    ) {
//        myLogE("layoutChildren", "booooommmmm")
        val width: Int = right - left
        val count = childCount

        for (i in 0 until count) {
            val child = getChildAt(i)
            if (child.visibility != GONE) {
                val lp = child.layoutParams as LayoutParams
                lp.childIndex = i
//                由于onLayout()这个方法当子View发生变化时会多次调用,因此要注意不能在这里重复初始化!!!!
//                初始化时,默认里面三个子View,1号在中间,0号左边,2号右边(012顺序,之后换位也不会影响相对位置)
                if (!child0Initialized || !child2Initialized) {
                    if (0 == lp.childIndex) {
                        lp.offset = -width
                        child0Initialized = true
                    } else if (2 == lp.childIndex) {
                        lp.offset = width
                        child2Initialized = true
                    }
                }

                child.layout(
                    lp.offset,
                    0,
                    lp.offset + child.measuredWidth,
                    0 + child.measuredHeight
                )
            }
        }
    }

    private fun findPageByLocation(location: Int): ImageView {
        val count = childCount
        for (i in 0 until count) {
            val child = getChildAt(i)
            val lp = child.layoutParams as LayoutParams
            if (location == lp.location) {
                val childRoot = child as ViewGroup
                val page = childRoot.getChildAt(0) as ImageView
                return page
            }
        }
        throw IllegalStateException("cannot find location:$location")
    }

    private fun findBookmarkByLocation(location: Int): ImageView {
        val count = childCount
        for (i in 0 until count) {
            val child = getChildAt(i)
            val lp = child.layoutParams as LayoutParams
            if (location == lp.location) {
                val childRoot = child as ViewGroup
                val bookmark = childRoot.getChildAt(1) as ImageView
                return bookmark
            }
        }
        throw IllegalStateException("cannot find location:$location")
    }

    /**
     * @return Pair.first 页面,Pair.second 书签标志
     * */
    private fun findPageAndBookmarkByLocation(location: Int): Pair<ImageView, ImageView> {
        val count = childCount
        for (i in 0 until count) {
            val child = getChildAt(i)
            val lp = child.layoutParams as LayoutParams
            if (location == lp.location) {
                val childRoot = child as ViewGroup
                val page = childRoot.getChildAt(0) as ImageView
                val bookmark = childRoot.getChildAt(1) as ImageView
                return Pair(page, bookmark)
            }
        }
        throw IllegalStateException("cannot find location:$location")
    }

    fun findChildByIndex(index: Int): View? {
        return getChildAt(index)
    }

    /**
     * 点按换页时的方法,不用播放动画
     * */
    override fun scrollToLeftWithoutAnima() {
        scrollTo(middleScrollX - measuredWidth, 0)
        middleScrollX = scrollX

        turnToLeftInternal()
    }

    override fun scrollToRightWithoutAnima() {
        scrollTo(middleScrollX + measuredWidth, 0)
        middleScrollX = scrollX

        turnToRightInternal()
    }

    /**
     * 给蓝牙翻页设备使用,会有翻页动画。
     * 要在外部检测是否能够翻页
     * */
    override fun smoothScrollToLeftWholeDistance() {
//        若仍在播放翻页动画,直接返回
        if (isPlayingAnima()) return

//        让按键时播放的滑动动画稍快一些
        val duration = (animaTime * ANIMA_SPEED_RATIO).toInt()
        val destX = middleScrollX - measuredWidth
        smoothScrollToX(destX, duration, TURN_TO_LEFT)
        middleScrollX = destX
    }

    private fun smoothScrollToLeftInternal() {
        val d = measuredWidth - abs(scrollX - middleScrollX)
        val duration = animaTime * d / measuredWidth
        val destX = middleScrollX - measuredWidth
        smoothScrollToX(destX, duration, TURN_TO_LEFT)
        middleScrollX = destX
    }

    override fun smoothScrollToRightWholeDistance() {
//        若仍在播放翻页动画,直接返回
        if (isPlayingAnima()) return

//        让按键时播放的滑动动画稍快一些
        val duration = (animaTime * ANIMA_SPEED_RATIO).toInt()
        val destX = middleScrollX + measuredWidth
        smoothScrollToX(destX, duration, TURN_TO_RIGHT)
        middleScrollX = destX
    }

    private fun smoothScrollToRightInternal() {
        val d = measuredWidth - abs(scrollX - middleScrollX)
        val duration = animaTime * d / measuredWidth
        val destX = middleScrollX + measuredWidth
        smoothScrollToX(destX, duration, TURN_TO_RIGHT)
        middleScrollX = destX
    }

    /**
     * 使用了手指滑动,然而不能够滑动到其他页面,恢复成原状
     * */
    override fun returnOriginalPageWithAnima() {
//        myLogE("ThreeImagesViewer", "restoreToOriginalState")
//        给个比较长的回复时间
//        val d = measuredWidth - abs(scrollX - middleScrollX)
//        val duration = animaTime * d / measuredWidth
        val duration = restorePageDuration()
        val destX = middleScrollX
        smoothScrollToX(destX, duration, DID_NOT_CHANGE_PAGE)
    }

    private fun restorePageDuration(): Int = animaTime / 2

    override fun scrollToX(deltaX: Int) {
//        myLogE(TAG, " scrollToX deltaX:$deltaX")
        scrollTo(middleScrollX + deltaX, 0)
    }

    /**
     *有通过手指滑动时使用此方法,播放动画走完剩余行程
     * */
    override fun scrollToLeftOrRightPageWithAnima(
        hasLeft: Boolean,
        hasRight: Boolean,
        speedEnough: Boolean,
    ) {
//        与记录当前中间页的scrollX进行对比
//        无论左右翻页都可以使用这个代码
        val scrolledX = abs(scrollX - middleScrollX)
//        myLogE("scrollToLeftOrRightPage", "scrolledX:$scrolledX")
        if (scrollX > middleScrollX) {
//            向右翻页
//            myLogE("scrollToLeftOrRightPage", "scrollX > middleScrollX")
            if (hasRight && (scrolledX > minSlop || speedEnough)) {

                smoothScrollToRightInternal()
            } else {
                returnOriginalPageWithAnima()
                if (!hasRight)
                    listener?.onReachedTheFarRight()
            }
        } else {
//            myLogE("scrollToLeftOrRightPage", "scrollX <= middleScrollX")
            if (hasLeft && (scrolledX > minSlop || speedEnough)) {

                smoothScrollToLeftInternal()
            } else {
                returnOriginalPageWithAnima()
                if (!hasLeft)
                    listener?.onReachedTheFarLeft()
            }
        }

    }

    private fun smoothScrollToX(destX: Int, duration: Int, mode: Int) {
        val startX = scrollX
        val deltaX = destX - startX
        scroller.startScroll(startX, 0, deltaX, 0, duration)
        invalidate()

        //        改为不用等动画播放完再预载
        when (mode) {
            TURN_TO_LEFT -> {
                turnToLeftInternal()
            }

            TURN_TO_RIGHT -> {
                turnToRightInternal()
            }
        }
    }

    /**
     * view只要重绘就会调用此方法
     * */
    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            postInvalidate()
        }
    }

    private fun turnToRightInternal() {
        turnToRightPage()
        listener?.onTurnToRight()
    }

    private fun turnToLeftInternal() {
        turnToLeftPage()
        listener?.onTurnToLeft()
    }

    override fun isPlayingAnima(): Boolean {
        return !scroller.isFinished
    }

    override fun abortAnima() = scroller.abortAnimation()

    /**
     * 维护新的页面位置并复用位于右边的页面
     * */
    private fun turnToLeftPage() {
        val count = childCount
        for (i in 0 until count) {
            val child = getChildAt(i)
            val lp = child.layoutParams as LayoutParams
            if (LOCATION_LEFT == lp.location) {
//                myLogE("turnToLeftPage", "LOCATION_LEFT == lp.location:$i")
//                左页变成中间页
                lp.location = LOCATION_MIDDLE
            } else if (LOCATION_MIDDLE == lp.location) {
//                myLogE("turnToLeftPage", "LOCATION_MIDDLE == lp.location:$i")
//                中间页变成右页
                lp.location = LOCATION_RIGHT
            } else if (LOCATION_RIGHT == lp.location) {
//                myLogE("turnToLeftPage", "LOCATION_RIGHT == lp.location:$i")
//                右页复用成左页
                lp.location = LOCATION_LEFT
                lp.offset = child.left - 3 * measuredWidth
                child.layout(
                    lp.offset,
                    0,
                    lp.offset + child.measuredWidth,
                    0 + child.measuredHeight
                )
            }
        }
    }

    /**
     * @return 需要复用的imageView的索引
     * */
    private fun turnToRightPage() {
        val count = childCount
        for (i in 0 until count) {
            val child = getChildAt(i)
            val lp = child.layoutParams as LayoutParams
            if (LOCATION_RIGHT == lp.location) {
//                myLogE("turnToRightPage", "LOCATION_RIGHT == lp.location:$i")
//                右页变成中间页
                lp.location = LOCATION_MIDDLE
            } else if (LOCATION_MIDDLE == lp.location) {
//                myLogE("turnToRightPage", "LOCATION_MIDDLE == lp.location:$i")
//                中间页变成左页
                lp.location = LOCATION_LEFT
            } else if (LOCATION_LEFT == lp.location) {
//                myLogE("turnToRightPage", "LOCATION_LEFT == lp.location:$i")
//                左页复用成右页
                lp.location = LOCATION_RIGHT
                lp.offset = child.left + 3 * measuredWidth
                child.layout(
                    lp.offset,
                    0,
                    lp.offset + child.measuredWidth,
                    0 + child.measuredHeight
                )
            }
        }
    }

    class LayoutParams : ViewGroup.LayoutParams {
        /**
         * Location
         */
        var location = LOCATION_MIDDLE

        /**
         * Current child index within the ViewPager that this view occupies
         */
        var childIndex: Int = 0

        var offset = 0

        constructor() : super(MATCH_PARENT, MATCH_PARENT)

        constructor(c: Context, attrs: AttributeSet?) : super(c, attrs)
    }

    override fun generateDefaultLayoutParams(): LayoutParams {
        return LayoutParams()
    }

    override fun generateLayoutParams(p: ViewGroup.LayoutParams?): ViewGroup.LayoutParams {
        return generateDefaultLayoutParams()
    }

    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return LayoutParams(context, attrs)
    }

    override fun checkLayoutParams(p: ViewGroup.LayoutParams?): Boolean {
        return p is LayoutParams
    }

    override fun setThreePagesBackgroundColor(color: Int) {
        for (i in 0 until childCount) {
            val childRoot = getChildAt(i) as ViewGroup
            childRoot.getChildAt(0).setBackgroundColor(color)
        }
    }

    override fun readyForFirstShowMiddlePage() {

    }

    override fun firstShowMiddlePageBitmap(
        page: ImageView,
        bitmap: Bitmap,
    ) {
        page.setPageBackgroundColor(bitmap)
        page.setBitmapWithFadeIn(bitmap)
    }

    override fun firstShowMiddlePageDrawable(
        page: ImageView,
        drawable: Drawable,
    ) {
        page.setDrawableAutoPlayAnima(drawable)
    }

    override fun middlePageAndBookmark() = findPageAndBookmarkByLocation(LOCATION_MIDDLE)
    override fun nextPageAndBookmark(): Pair<ImageView, ImageView> {
        return findPageAndBookmarkByLocation(nextLocation())
    }

    override fun previousPageAndBookmark(): Pair<ImageView, ImageView> {
        return findPageAndBookmarkByLocation(previousLocation())
    }

    private fun previousLocation(): Int = if (readingDirectionLeft)
        LOCATION_RIGHT
    else
        LOCATION_LEFT

    override fun middlePage() = findPageByLocation(LOCATION_MIDDLE)
    override fun nextPage(): ImageView {
        return findPageByLocation(nextLocation())
    }

    private fun nextLocation(): Int = if (readingDirectionLeft)
        LOCATION_LEFT
    else
        LOCATION_RIGHT

    override fun previousPage(): ImageView {
        return findPageByLocation(previousLocation())
    }

    override fun middleBookmark() = findBookmarkByLocation(LOCATION_MIDDLE)
    override fun nextBookmark(): ImageView {
        return findBookmarkByLocation(nextLocation())
    }

    override fun previousBookmark(): ImageView {
        return findBookmarkByLocation(previousLocation())
    }

    companion object {
        const val TAG = "SlidingPagesLayout"

        const val LOCATION_MIDDLE = 0
        const val LOCATION_LEFT = 1
        const val LOCATION_RIGHT = 2
        const val ANIMA_MILLISECOND = 400
        const val ANIMA_SPEED_RATIO = 0.7f

        const val DID_NOT_CHANGE_PAGE = -1
        const val TURN_TO_LEFT = 0
        const val TURN_TO_RIGHT = 1
    }
}

代码虽不短,逻辑也不复杂,不过是重写layout方法水平排列三个ImageView,并在翻页动作后复用。翻页动效则通过Scroller实现。 IHorizontalPagesLayout接口定义供外部调用的公共方法,更是不值一提。

AI给出的自定义Compose

把上述完整代码喂给AI,指令其给出Compose实现。

由于声明式与命令式存在天然差异,我们几乎可以预见自定义Compose的代码会简短非常多。

令人无语的是,AI一开始还不给,说什么要这么做:

要在 Compose 中实现类似 SlidingPagesLayout 的功能,最现代且高效的方法是使用 > androidx.compose.foundation.pager.HorizontalPager。 但是,考虑到你提供的 SlidingPagesLayout 具有非常具体的三页复用逻辑和书签处理,我为你提供两种方案:

1.方案 A:原生 Compose 实现 (推荐) - 使用 HorizontalPager 并支持阅读方向,这是 Compose 的标准写法。

2.方案 B:View 包装器 - 如果你希望保留 SlidingPagesLayout 复杂的底层复用逻辑和特定的 ImageView 处理,可以使用 AndroidView 封装。

我再次重申这两种方案我都不采纳,你做个类似功能的自定义Compose出来,放到ui目录下,它才勉为其难地开始自定义Compose。

这可能又到AI的能力边界了,它给出了十分幽默的输出,在我数次追问后,才渐渐变得像那么一回事。

只不过到这时,又在提示说超量使用了,也不知道是Google见我一个劲白嫖,悄咪咪加了限制,还是这区区百行的代码,确实烧掉了难以计数的Token。

总之,它的自定义Compose长这样:

kotlin 复制代码
/**
 * 对应原生 SlidingPagesLayout 功能的自定义 Compose 组件
 * 采用物理坐标连续逻辑,支持手动拖拽、速度检测和阅读方向切换。
 * 核心原理:使用全局累加的 offset,通过数学计算只渲染视口内的三页,实现真正的物理连续复用。
 */
@Composable
fun SlidingLayout(
    modifier: Modifier = Modifier,
    pageCount: Int,                // 总页数
    initialPage: Int = 0,          // 初始进入的页码
    readingDirectionLeft: Boolean = false, // 阅读方向:false 为从左往右 (LTR),true 为从右往左 (RTL, 日漫模式)
    onPageChanged: (Int) -> Unit = {},     // 页码改变回调
    content: @Composable (pageIndex: Int) -> Unit // 每一页的具体 UI 内容
) {
    // 内部维护的逻辑当前页,用于同步外部和回调
    var currentPage by remember { mutableIntStateOf(initialPage) }

    // 全局物理偏移量。这是一个连续的数值,代表整个"长卷"相对于视口的位移。
    // 每一页在视口中的绝对物理位置 = (逻辑索引 - initialPage) * 方向系数 * 屏幕宽度 + totalOffset
    val totalOffset = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val velocityTracker = remember { VelocityTracker() }

    // 当外部 initialPage 发生变化时(如从目录跳转),同步内部状态
    LaunchedEffect(initialPage) {
        if (currentPage != initialPage) {
            currentPage = initialPage
            // 跳转到目标页时,将偏移量重置为 0,因为此时 initialPage 重新成为了物理坐标的参考原点
            totalOffset.snapTo(0f)
        }
    }

    BoxWithConstraints(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(pageCount, readingDirectionLeft) {
                val width = size.width.toFloat()

                detectHorizontalDragGestures(
                    onDragStart = {
                        velocityTracker.resetTracking() // 拖拽开始,重置速度追踪
                    },
                    onHorizontalDrag = { change, dragAmount ->
                        change.consume()
                        velocityTracker.addPosition(change.uptimeMillis, change.position)

                        // 计算基于 initialPage 的物理边界偏移
                        val minOffset =
                            if (!readingDirectionLeft) -(pageCount - 1 - initialPage) * width else -(initialPage) * width
                        val maxOffset =
                            if (!readingDirectionLeft) (initialPage) * width else (pageCount - 1 - initialPage) * width

                        val newOffset = totalOffset.value + dragAmount

                        // 边界阻尼逻辑:如果超过了第一页或最后一页,滑动距离乘以 0.3,模拟拉力感
                        if (newOffset !in minOffset..maxOffset) {
                            scope.launch { totalOffset.snapTo(totalOffset.value + dragAmount * 0.3f) }
                        } else {
                            scope.launch { totalOffset.snapTo(newOffset) }
                        }
                    },
                    onDragEnd = {
                        val velocity = velocityTracker.calculateVelocity().x

                        val directionFactor = if (!readingDirectionLeft) 1f else -1f

                        // 计算当前偏移量对应逻辑上的"页码偏移"(相对于 initialPage)
                        val rawPageOffset = -totalOffset.value / (width * directionFactor)
                        var targetPageOffset = rawPageOffset.roundToInt()

                        // 速度判定:如果松手速度够快,强制切换到下一页或上一页
                        val speedThreshold = 1000f
                        if (abs(velocity) > speedThreshold) {
                            targetPageOffset = if (velocity * directionFactor > 0) {
                                // 速度方向指向上一页 (页码减小方向)
                                if (rawPageOffset > targetPageOffset) targetPageOffset else targetPageOffset - 1
                            } else {
                                // 速度方向指向下一页 (页码增大方向)
                                if (rawPageOffset < targetPageOffset) targetPageOffset else targetPageOffset + 1
                            }
                        }

                        // 最终边界限制,确保不会翻出 0 到 pageCount-1 的范围
                        val finalPageOffset = targetPageOffset.coerceIn(
                            -(initialPage),
                            (pageCount - 1 - initialPage)
                        )

                        val targetLogicalPage = initialPage + finalPageOffset

                        scope.launch {
                            // 播放平滑翻页动画,对齐到目标页的物理坐标
                            totalOffset.animateTo(
                                targetValue = -finalPageOffset * width * directionFactor,
                                animationSpec = tween(durationMillis = 300)
                            )

                            // 动画结束,如果页码变了,通知外部回调
                            if (currentPage != targetLogicalPage) {
                                currentPage = targetLogicalPage
                                onPageChanged(currentPage)
                            }
                        }
                    }
                )
            }
    ) {
        val widthPx = constraints.maxWidth.toFloat()
        val directionFactor = if (!readingDirectionLeft) 1f else -1f

        // 计算当前视觉中心最接近哪一页(用于确定渲染窗口)
        val currentCenterOffset = (-totalOffset.value / (widthPx * directionFactor)).roundToInt()
        val currentCenterPage = initialPage + currentCenterOffset

        // 滑动窗口复用逻辑:只渲染视觉中心页及其左右两页,总共最多 3 页
        for (i in -1..1) {
            val pageIdx = currentCenterPage + i
            if (pageIdx in 0 until pageCount) {
                // 计算每一页当前的绝对物理位置
                // 位置 = (逻辑页码 - 初始页码) * 宽度 * 方向系数 + 全局偏移量
                val xPos = (pageIdx - initialPage) * widthPx * directionFactor + totalOffset.value

                // 使用 key(pageIdx) 确保 Compose 能够正确识别并复用已有的页面 Composable
                key(pageIdx) {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .offset { IntOffset(xPos.roundToInt(), 0) }
                    ) {
                        content(pageIdx)
                    }
                }
            }
        }
    }
}

给AI修Bug还是重写?

测试之后,果然有重大bug:翻页后会直接再度跳页。

我们尝试让AI自己修复:

@BrowserScreen.kt @SlidingLayout.kt 测试中发现,翻页后会再跳到下一页;翻到上页同理

它这样回复,表示要修改Effect实现:

kotlin 复制代码
     // 当外部 initialPage 发生变化时(如从目录跳转),同步内部状态
     LaunchedEffect(initialPage) {
         // 无论如何都要同步 currentPage,确保内部状态与外部一致
         currentPage = initialPage
         // 🌟 关键修复:只要 initialPage 发生了变化,就必须重置偏移量为 0。
         // 因为所有的页面坐标计算都是以 initialPage 为基准的:
         // xPos = (pageIdx - initialPage) * width * direction + totalOffset
         // 当 initialPage 变为新页码时,只有 totalOffset 归零,新页码才会正好处于中心 (xPos = 0)
         totalOffset.snapTo(0f)
     }

再度测试,还是有会有偶发上述Bug的情况,而即便侥幸不触发上述Bug,在同一个页面上也会有明显的闪烁。(这在机器上有明显体现,而录屏则看不出来。所以不再给出示意动图。)

毫无疑问,这是发生了完全没有必要的重组。

看了两眼它的代码,我认为核心实现逻辑就存在重大问题,没救了必须整个推翻重写。

感兴趣的读者,可以挑战把它的代码改成可用看看。

由于本文是"Vibe coding指南",不是"自定义Compose指南",请恕我不给出对应Compose。若感觉存在疑问,可参阅这个Compose文档

这个例子,建立在我完全熟悉相关技术细节的前提下。

而若是反过来,我们现在让AI在帮我们实现我们不了解的领域,怎么办?你会听信商家宣传,立即充值购买最贵的订阅吗?

反正我不会,根据这么多的商业实例,我的判断是最强AI终将是免费AI。我们可以拭目以待这个预言五年内是否应验。

下一节,我们姑且再挑战一下Vibe coding自动分页。

相关推荐
河北清兮网络科技1 天前
深度解析:2026石家庄短视频APP开发真实成本、隐性开销与避坑方案
大数据·小程序·app·短剧app·广告联盟
默默且听风2 天前
Ubuntu 22 环境下 VS Code Codex 插件无法打开的排查与修复记录
后端·ai编程·vibecoding
暗冰ཏོ2 天前
Flutter 从入门到项目实战:Dart 基础、跨平台开发、App 架构与上线发布完整指南
flutter·架构·app·安卓·应用开发
闲猫2 天前
从0到1完整开发Smartshell最后沉淀出的Cursor开发规则
linux·运维·堡垒机·cursor·vibecoding
gxf5203088069883 天前
Flutter 裁剪图片
前端·app
码哥字节3 天前
我把 Matt Pocock 的 18 个 Skill 全用了一遍,才发现自己一直在瞎用 AI
ai编程·claude·vibecoding
私人珍藏库3 天前
【Android】 VidFetch一键下载各大平台视-内置播放器
android·app·工具·软件·多功能
私人珍藏库4 天前
【Android】Wallcraft 3.62.0-最强4 K壁纸软件-解锁高级版
android·智能手机·app·工具·软件·多功能
wangruofeng4 天前
iOS、Android、Flutter 2026 流行框架对比
前端框架·app