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

20260621

读者应具备的知识基础

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

关于HorizontalPager的前文勘误

Android studio 升级至2026.1.1 Patch 1以后,Gemini能力似乎有所提高。同时,新的用量可视化UI很不错,可以更好地安排白嫖了:)

之前文章中我的测试不够严谨,实际上对于HorizontalPager,由于放大效果是用Modifier.graphicsLayer实现的,这只是改变视觉效果,本身不改变UI控件大小,因此同样可以实现在图片放大后越界翻到下一页。效果像是这样:

这是通过写一点小思路,让升级后的Gemini Vibe coding完成的,过程不值一提,其相关代码如下:

kotlin 复制代码
@Composable
private fun HorizontalPagingLayout(
    settingsUiState: BrowserSettingsUiState,
    maxPageIndexSize: Int,
    currentPageIndex: Int,
    readingDirection: ReadingDirection,
    showMenu: Boolean,
    onToggleMenu: () -> Unit,
    onPageChanged: (Int) -> Unit,
    onShowMessage: (String) -> Unit,
    getComicPage: suspend (Int) -> ImageBitmap?
) {
    val pagerState = rememberPagerState(
        initialPage = currentPageIndex,
        pageCount = { maxPageIndexSize }
    )
    val coroutineScope = rememberCoroutineScope()
    val viewConfiguration = LocalViewConfiguration.current
    val reachedFirstPageText = stringResource(R.string.reached_first_page)
    val reachedLastPageText = stringResource(R.string.reached_last_page)
    var lastBoundaryMessageTime by remember { mutableLongStateOf(0L) }

    val nestedScrollConnection = remember(readingDirection) {
        val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT
        object : NestedScrollConnection {
            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                if (source == NestedScrollSource.UserInput && abs(available.x) > 3f) {
                    val now = System.currentTimeMillis()
                    if (now - lastBoundaryMessageTime > 2500) {
                        // available.x > 0 是物理左边界,available.x < 0 是物理右边界
                        val isAtFirstPage = if (isRtl) available.x < 0 else available.x > 0
                        onShowMessage(if (isAtFirstPage) reachedFirstPageText else reachedLastPageText)
                        lastBoundaryMessageTime = now
                    }
                }
                return Offset.Zero
            }
        }
    }

    // 同步外部页码变化到 Pager (如 Slider 操作)
    LaunchedEffect(currentPageIndex) {
        if (currentPageIndex != pagerState.currentPage) {
            pagerState.scrollToPage(currentPageIndex)
        }
    }

    var scale by remember { mutableFloatStateOf(1f) }
    var offsetX by remember { mutableFloatStateOf(0f) }
    var offsetY by remember { mutableFloatStateOf(0f) }
    var isInteracting by remember { mutableStateOf(false) }
    // 🌟 记录当前正在"放大"的页码
    var scaledPageIndex by remember { mutableIntStateOf(pagerState.currentPage) }

    // 🌟 只有当翻页彻底结束且停稳时,才重记当前页码并重置缩放状态
    LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress, isInteracting) {
        // ⚡ 核心修复:只有在不滚动且偏移量归零时才重置,防止慢滑超过 50% 时重置
        if (!isInteracting && !pagerState.isScrollInProgress &&
            abs(pagerState.currentPageOffsetFraction) < 0.001f &&
            pagerState.currentPage != scaledPageIndex
        ) {
            onPageChanged(pagerState.currentPage)
            scale = 1f
            offsetX = 0f
            offsetY = 0f
            scaledPageIndex = pagerState.currentPage
        }
    }

    val isScaled by remember { derivedStateOf { scale > 1f } }
    val currentAnimJob = remember { mutableStateOf<Job?>(null) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(showMenu) {
                detectTapGestures(
                    onDoubleTap = { tapOffset ->
                        if (showMenu || !settingsUiState.doubleTapToZoom) return@detectTapGestures

                        // 🌟 手势开始前:如果当前没放大,确保缩放作用在当前视野中心页
                        if (scale <= 1f) {
                            scaledPageIndex = pagerState.currentPage
                        }

                        currentAnimJob.value?.cancel()
                        currentAnimJob.value = coroutineScope.launch {
                            if (scale > 1f) {
                                val initialScale = scale
                                val initialX = offsetX
                                val initialY = offsetY
                                AnimationState(initialValue = 0f).animateTo(1f) {
                                    scale = initialScale + (1f - initialScale) * this.value
                                    offsetX = initialX * (1f - this.value)
                                    offsetY = initialY * (1f - this.value)
                                }
                            } else {
                                val targetScale = settingsUiState.doubleTapZoomRatio
                                val cx = size.width / 2f
                                val cy = size.height / 2f

                                val boundX = max(0f, (size.width * targetScale - size.width) / 2f)
                                val boundY = max(0f, (size.height * targetScale - size.height) / 2f)

                                val targetX =
                                    ((cx - tapOffset.x) * targetScale).coerceIn(-boundX, boundX)
                                val targetY =
                                    ((cy - tapOffset.y) * targetScale).coerceIn(-boundY, boundY)

                                val initialScale = scale
                                val initialX = offsetX
                                val initialY = offsetY

                                AnimationState(initialValue = 0f).animateTo(1f) {
                                    scale = initialScale + (targetScale - initialScale) * this.value
                                    offsetX = initialX + (targetX - initialX) * this.value
                                    offsetY = initialY + (targetY - initialY) * this.value
                                }
                            }
                        }
                    },
                    onTap = { tapOffset ->
                        if (showMenu) {
                            onToggleMenu()
                            return@detectTapGestures
                        }

                        val screenWidth = size.width
                        val screenHeight = size.height
                        val x = tapOffset.x
                        val y = tapOffset.y

                        val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT

                        when (settingsUiState.tapToTurnPageMode) {
                            TapToTurnPageMode.TOP_BOTTOM -> {
                                when {
                                    y < screenHeight / 3f -> {
                                        if (!isScaled) {
                                            if (pagerState.canScrollBackward) {
                                                coroutineScope.launch {
                                                    pagerState.animateScrollToPage(pagerState.currentPage - 1)
                                                }
                                            } else {
                                                onShowMessage(reachedFirstPageText)
                                            }
                                        }
                                    }

                                    y > screenHeight * 2 / 3f -> {
                                        if (!isScaled) {
                                            if (pagerState.canScrollForward) {
                                                coroutineScope.launch {
                                                    pagerState.animateScrollToPage(pagerState.currentPage + 1)
                                                }
                                            } else {
                                                onShowMessage(reachedLastPageText)
                                            }
                                        }
                                    }

                                    else -> onToggleMenu()
                                }
                            }

                            TapToTurnPageMode.LEFT_RIGHT -> {
                                val isPrev =
                                    if (isRtl) x > screenWidth * 2 / 3f else x < screenWidth / 3f
                                val isNext =
                                    if (isRtl) x < screenWidth / 3f else x > screenWidth * 2 / 3f
                                when {
                                    isPrev -> {
                                        if (!isScaled) {
                                            if (pagerState.canScrollBackward) {
                                                coroutineScope.launch {
                                                    pagerState.animateScrollToPage(pagerState.currentPage - 1)
                                                }
                                            } else {
                                                onShowMessage(reachedFirstPageText)
                                            }
                                        }
                                    }

                                    isNext -> {
                                        if (!isScaled) {
                                            if (pagerState.canScrollForward) {
                                                coroutineScope.launch {
                                                    pagerState.animateScrollToPage(pagerState.currentPage + 1)
                                                }
                                            } else {
                                                onShowMessage(reachedLastPageText)
                                            }
                                        }
                                    }

                                    else -> onToggleMenu()
                                }
                            }

                            TapToTurnPageMode.NONE -> {
                                if (x in (screenWidth / 3f)..(screenWidth * 2 / 3f) &&
                                    y in (screenHeight / 3f)..(screenHeight * 2 / 3f)
                                ) {
                                    onToggleMenu()
                                }
                            }
                        }
                    }
                )
            }
            .pointerInput(showMenu) {
                if (showMenu) return@pointerInput
                awaitEachGesture {
                    awaitFirstDown(requireUnconsumed = false)
                    currentAnimJob.value?.cancel()
                }
            }
            .pointerInput(showMenu) {
                if (showMenu) return@pointerInput
                awaitEachGesture {
                    var pastTouchSlop = false
                    val touchSlop = viewConfiguration.touchSlop
                    var accumulatedZoom = 1f
                    var accumulatedPan = Offset.Zero

                    awaitFirstDown(requireUnconsumed = false)
                    isInteracting = true

                    try {
                        // 🌟 双指操作前:同步缩放目标
                        if (scale <= 1f) {
                            scaledPageIndex = pagerState.currentPage
                        }

                        do {
                            val event = awaitPointerEvent()
                            if (event.changes.size > 1) {
                                val zoomChange = event.calculateZoom()
                                val panChange = event.calculatePan()
                                val centroid = event.calculateCentroid(useCurrent = true)

                                if (!pastTouchSlop) {
                                    accumulatedZoom *= zoomChange
                                    accumulatedPan += panChange
                                    val centroidSize =
                                        event.calculateCentroidSize(useCurrent = false)
                                    if (abs(1 - accumulatedZoom) * centroidSize > touchSlop ||
                                        accumulatedPan.getDistance() > touchSlop
                                    ) pastTouchSlop = true
                                }

                                if (pastTouchSlop) {
                                    val oldScale = scale
                                    val newScale =
                                        (scale * zoomChange).coerceIn(1f, MAX_SCALE_RATIO)

                                    if (newScale > 1f || oldScale > 1f) {
                                        val boundX =
                                            max(0f, (size.width * newScale - size.width) / 2f)
                                        val boundY =
                                            max(0f, (size.height * newScale - size.height) / 2f)

                                        if (centroid != Offset.Unspecified) {
                                            val cx = size.width / 2f
                                            val cy = size.height / 2f
                                            offsetX =
                                                (offsetX * zoomChange + (centroid.x - cx) * (1 - zoomChange) + panChange.x)
                                                    .coerceIn(-boundX, boundX)
                                            offsetY =
                                                (offsetY * zoomChange + (centroid.y - cy) * (1 - zoomChange) + panChange.y)
                                                    .coerceIn(-boundY, boundY)
                                        }
                                        scale = newScale
                                    } else {
                                        scale = 1f; offsetX = 0f; offsetY = 0f
                                    }
                                }
                                event.changes.forEach { if (it.positionChanged()) it.consume() }
                            }
                        } while (event.changes.any { it.pressed })
                    } finally {
                        isInteracting = false
                    }
                }
            }
            .pointerInput(isScaled, showMenu) {
                if (!isScaled || showMenu) return@pointerInput

                val decaySpec = splineBasedDecay<Float>(this)
                val velocityTracker = VelocityTracker()

                awaitEachGesture {
                    awaitFirstDown(requireUnconsumed = false)
                    isInteracting = true
                    try {
                        velocityTracker.resetTracking()

                        var pastTouchSlop = false
                        val touchSlop = viewConfiguration.touchSlop
                        var accumulatedPan = Offset.Zero

                        var isCrossingBoundary = false
                        do {
                            val event = awaitPointerEvent()
                            // ⚡【核心隔离】:如果发现多于一根手指,单指滑动逻辑立刻自动退出,由多指块接管
                            if (event.changes.size > 1) break

                            val change = event.changes.first()
                            val panChange = event.calculatePan()

                            if (!pastTouchSlop) {
                                accumulatedPan += panChange
                                if (accumulatedPan.getDistance() > touchSlop) pastTouchSlop = true
                            }

                            if (pastTouchSlop) {
                                velocityTracker.addPosition(change.uptimeMillis, change.position)

                                val boundX = max(0f, (size.width * scale - size.width) / 2f)
                                val boundY = max(0f, (size.height * scale - size.height) / 2f)

                                val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT

                                // ⚡ 核心逻辑:如果在跨界中,或者即将跨界
                                val expectedOffsetX = offsetX + panChange.x
                                val clampedX = expectedOffsetX.coerceIn(-boundX, boundX)
                                val overflowX = expectedOffsetX - clampedX

                                if (isCrossingBoundary || overflowX != 0f) {
                                    isCrossingBoundary = true
                                    // ⚡ 只要进入了跨界状态,后续的所有 x 轴位移都直接交给 Pager 处理
                                    // 这样可以保证反向滑动(把下一页收回去)也是完全跟手的
                                    val scrollDelta = if (isRtl) panChange.x else -panChange.x
                                    pagerState.dispatchRawDelta(scrollDelta)
                                } else {
                                    offsetX = clampedX
                                }

                                offsetY = (offsetY + panChange.y).coerceIn(-boundY, boundY)

                                if (change.positionChanged()) change.consume()
                            }
                        } while (event.changes.any { it.pressed })

                        if (isCrossingBoundary) {
                            val velocity = velocityTracker.calculateVelocity().x
                            coroutineScope.launch {
                                val isRtl = readingDirection == ReadingDirection.RIGHT_TO_LEFT
                                val offset = pagerState.currentPageOffsetFraction

                                // ⚡ 判定翻页目标:
                                // 1. 计算相对于起点(scaledPageIndex)的移动距离
                                val diff = pagerState.currentPage - scaledPageIndex
                                val progress = diff + offset

                                val targetPage = when {
                                    abs(velocity) > 500f -> {
                                        // 根据滑动速度方向判定翻页(LTR: 速度负向为前进,RTL: 速度正向为前进)
                                        val isForward = if (isRtl) velocity > 0 else velocity < 0
                                        if (isForward) scaledPageIndex + 1 else scaledPageIndex - 1
                                    }

                                    abs(progress) > 0.2f -> {
                                        // 只要位移超过 20%,则向位移方向翻页
                                        if (progress > 0) scaledPageIndex + 1 else scaledPageIndex - 1
                                    }

                                    else -> scaledPageIndex
                                }.coerceIn(0, maxPageIndexSize - 1)

                                pagerState.animateScrollToPage(targetPage)
                            }
                            return@awaitEachGesture
                        }

                        // 抬手后的惯性滑动逻辑
                        val velocity = velocityTracker.calculateVelocity()
                        if (abs(velocity.x) > 100f || abs(velocity.y) > 100f) {
                            currentAnimJob.value = coroutineScope.launch {
                                val boundX = max(0f, (size.width * scale - size.width) / 2f)
                                val boundY = max(0f, (size.height * scale - size.height) / 2f)

                                launch {
                                    var lastX = offsetX
                                    AnimationState(
                                        initialValue = offsetX,
                                        initialVelocity = velocity.x
                                    )
                                        .animateDecay(decaySpec) {
                                            val delta = this.value - lastX
                                            lastX = this.value

                                            val expectedOffsetX = offsetX + delta
                                            val clampedX = expectedOffsetX.coerceIn(-boundX, boundX)
                                            val overflowX = expectedOffsetX - clampedX

                                            if (overflowX != 0f) {
                                                // 🌟 翻页模式优化:放大时不执行跨页惯性
                                                offsetX = clampedX
                                                if (abs(this.velocity) < 10f) cancelAnimation()
                                            } else {
                                                offsetX = clampedX
                                            }
                                        }
                                }

                                launch {
                                    var lastY = offsetY
                                    AnimationState(
                                        initialValue = offsetY,
                                        initialVelocity = velocity.y
                                    )
                                        .animateDecay(decaySpec) {
                                            val delta = this.value - lastY
                                            lastY = this.value
                                            offsetY = (offsetY + delta).coerceIn(-boundY, boundY)
                                            if ((offsetY <= -boundY || offsetY >= boundY) && abs(
                                                    this.velocity
                                                ) < 10f
                                            ) cancelAnimation()
                                        }
                                }
                            }
                        }
                    } finally {
                        isInteracting = false
                    }
                }
            }
    ) {
        HorizontalPager(
            state = pagerState,
            reverseLayout = readingDirection == ReadingDirection.RIGHT_TO_LEFT,
            userScrollEnabled = !isScaled && !showMenu,
            modifier = Modifier
                .fillMaxSize()
                .nestedScroll(nestedScrollConnection)
        ) { pageIndex ->
            val state by produceState<BitmapLoadingState>(
                initialValue = BitmapLoadingState.Loading,
                pageIndex,
            ) {
                val res = getComicPage(pageIndex)

                value = if (null != res)
                    BitmapLoadingState.Success(res)
                else
                    BitmapLoadingState.Failure
            }

            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer {
                        // 🌟 核心改进:仅对 scaledPageIndex 施加变换,确保滑入的新页始终是 1x 不变形
                        val isTarget = pageIndex == scaledPageIndex
                        scaleX = if (isTarget) scale else 1f
                        scaleY = if (isTarget) scale else 1f
                        translationX = if (isTarget) offsetX else 0f
                        translationY = if (isTarget) offsetY else 0f
                    },
                contentAlignment = Alignment.Center
            ) {
                when (state) {
                    is BitmapLoadingState.Loading -> {
                        CircularProgressIndicator(modifier = Modifier.size(Dimen.circularProgressIndicatorSize))
                    }

                    is BitmapLoadingState.Success -> {
                        val bitmap = (state as BitmapLoadingState.Success).bitmap
                        Image(
                            bitmap = bitmap,
                            contentScale = ContentScale.Fit,
                            contentDescription = null,
                            modifier = Modifier.fillMaxSize()
                        )
                    }

                    is BitmapLoadingState.Failure -> {
                        Icon(
                            painter = painterResource(R.drawable.broken_image_24dp),
                            contentDescription = null,
                            tint = Color.LightGray,
                            modifier = Modifier.size(48.dp)
                        )
                    }
                }
            }
        }
    }
}

目前已知问题:

  • 放大后越界滑动,再反向滑动时,由于事件全交给了HorizontalPager处理,因此另一侧的页面会显现出来,产生页面重叠的非预期效果。

关于自定义Compose的提示

跳过重复测量,仅仅触发布局

HorizontalPager对于实现自动单双页会非常困难,因此我们采用自定义Compose的方案。如果你熟悉自定义View,实际上也大差不差。

首先是阶段变化,View的三阶段测量、布局、绘制到Compose中对应为组合、测量及布局、绘制

类似的阶段,定制方法却不同。比如对于测量、布局,自定义View中我们通过重写回调方法来完成对特定阶段的定制。而在Compose中,我们提供MeasurePolicy来完成测量及布局,比如一个支持滑动翻页的自定义Compose,MeasurePolicy的写法大体如下:

kotlin 复制代码
Layout(content = content) { measurables, constraints ->
// #scope 1
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        layout(constraints.maxWidth, constraints.maxHeight) {
        // #scope 2
//            来自 HorizontalPager 相关, PagerMeasure 的处理方案
            state.attachInvalidatorToScope()
            state.setWidth(placeables[0].width)
//            logE("---   layout")
            for (i in placeables.indices) {
                val placeable = placeables[i]
                val width = placeable.width
                placeable.placeRelative(
                    x = state.offset + getInherentXOffset(i, state.currentMiddlePos, width),
                    y = 0
                )
            }
        }
    }

其中,scope 1完成测量,而scope 2完成布局。由于Compose的重组区域以Scope进行判定,因此我们可以跳过重复测量,仅仅触发布局来完成UI的位移变化。你可以参阅HorizontalPager的源码。简单来说,原理还是自动观察State变化并响应那一套。

首先,定义这样一个类:

kotlin 复制代码
class OScopeInvalidator(
    private val state: MutableState<Unit> = mutableStateOf(Unit, neverEqualPolicy())
) {
    fun attachToScope() {
        state.value
    }

    fun invalidateScope() {
        state.value = Unit
    }
}

然后,在scope 2内调用state.attachInvalidatorToScope(),而在滑动处理中,在state.offset变化后同步调用state.invalidateScope()即可触发再次布局。

注意代码调用点,防止跳过重组失效

如上节所述,我们的自定义UI组件固定提供三个页面循环利用。每个页面通过物理索引位置唯一确定。比如:初始状态下,012三页,0是左页,1是中间页,2是右页。再经历一次向右翻页后,结构变化为201,即2变为左页,0变为中间页,1变为右页。

请看以下两段通过物理索引位置确定载入页面的代码:

kotlin 复制代码
repeat(3) { physicalSlot ->
    when (getLocation(physicalSlot, state.currentMiddlePos)) {
        PageLocation.LEFT -> {
            val key = keyProducer(leftPageIndex)
            ReusablePage(key, pageContent) // 调用点 A
        }
        PageLocation.MIDDLE -> {
            val key = keyProducer(currentPageIndex)
            ReusablePage(key, pageContent) // 调用点 B
        }
        PageLocation.RIGHT -> {
            val key = keyProducer(rightPageIndex)
            ReusablePage(key, pageContent) // 调用点 C
        }
    }
}
kotlin 复制代码
    repeat(3) { physicalSlot ->
        // 1. 先计算出这个插槽对应的逻辑页码
        val targetPageIndex = when (getLocation(physicalSlot, state.currentMiddlePos)) {
            PageLocation.LEFT -> leftPageIndex
            PageLocation.MIDDLE -> currentPageIndex
            PageLocation.RIGHT -> rightPageIndex
        }
        
        // 2. 根据页码生成 Key
        val key = keyProducer(targetPageIndex)
        
        // 3. 在 when 分支外部统一调用(确保调用点 ID 固定)
        ReusablePage(key, pageContent)
    }

从逻辑上看,它们的效果一致。

但是,同样在key不变的情况下,第一段无法跳过重组,第二段则可以顺利跳过。

其中的原因,AI总结如下:

在 Compose 中,识别一个 Composable 函数是否可以"跳过"不仅取决于它的参数(Key)是否相等,还取决于它在 插槽表(Slot Table) 中的位置 ID。这个位置 ID 是由编译器根据源代码的结构生成的。

假设当前 physicalSlot 为 1。

  • 1.翻页前:currentMiddlePos 为 1。getLocation(1, 1) 返回 MIDDLE。此时执行的是 调用点 B。Compose 在插槽表中记住了这个位置运行的是 key = B 的页面。
  • 2.翻页后:currentMiddlePos 变为 2。getLocation(1, 2) 返回 LEFT。此时执行流进入了另一个分支,运行的是 调用点 A。

对于 Compose 编译器来说: 虽然函数名都是 ReusablePage,参数 key 也刚好还是 B,但 调用点 A 和 调用点 B 是两个完全不同的"身份"。

  • Compose 看到"调用点 B"消失了,于是销毁它。
  • Compose 看到"调用点 A"出现了,于是将其视为 首次组合(Initial Composition)。
  • 既然是首次组合,ReusablePage 内部的代码逻辑就会完整运行一遍,表现出来就是"发生了重组"。

不要对ReusableContent传入null作为key

在自定义Compose中,为了极致性能,显然需要使用ReusableContent进行组件重用。其源码如下:

kotlin 复制代码
@Composable
public inline fun ReusableContent(key: Any?, content: @Composable () -> Unit) {
    currentComposer.startReusableGroup(reuseKey, key)
    content()
    currentComposer.endReusableGroup()
}

可以看到,其中的key参数是Any?类型,想当然地,我一开始设计了传入null用于指示空页面,结果报错了:

Error was captured in composition. androidx.compose.runtime.ComposeRuntimeError: Compose Runtime internal error. Unexpected or incorrect use of the Compose internal runtime API (Updating the data of a group that was not created with a data slot).

推测可能是自定义类型与null不可比产生的错误,我认为Google应当修改函数签名,去除可空。

解决方法也简单,就是在自定义类型中另外设计一个特殊值用于指示空页面。

自动单双页

Vibe coding悖论

旧有经验告诉我,这是个相当繁琐的功能。我们调整到Plan模式,看下AI有何高见。

先别写代码。做一个功能实现设计:在竖屏模式下的自动单页功能,当检测到页面是双页时,自动切分成左右两个单页。你详细给出实现原理的文档

完成之后,点击链接就能打开文档了。像是这样:

内容不多,也就一页半,十分粗糙,而且后面许多都是待实现的留空。如果让AI按这样施工,显然会出大问题。鼠标移到每一行上,右侧都是出现一个+按钮,可以添加评论,之后再让AI进一步完善。

对于这种非常小众的需求,AI的表现你只能自求多福。我们的项目是个个工具类App,根据我的经验,作为商业应用先不说比竞品体验更佳,即便不能持平,你也是基本争取不到付费用户。

也就是说,在这种核心功能上,你必须保证体验良好。

移动开发跟Web开发对于性能的要求完全就是两种情况,特别是图像处理是高负载操作,iOS或许还可以凭借强大机能硬吃,但在Android上你必须考虑你的广大受众机能一般。如果你有调研的话,你会发现付费榜第一的CDisplay性能表现就很好。

由于AI的设计实现完全达不到要求,我一开始有试图通过修改文档、追问来让工作推进下去------是的,我暂且忽略掉了即便给出完整实现方案,AI也无法实际落地的糟糕境地。但是越往下干,我越发感到情况不对:

  • 精准描述需求文档,以及写好技术文档,本身就是非常不简单的工作。如果你的编程经历足够长,可能你也跟我一样比较随意平时压根就不写这种东西,都是把重难点记在脑中,代码上加点注释了事:(
  • 与AI交流需要耗费巨大的沟通成本,如果是同做一个项目的人类工程师,彼此熟悉项目整体情况,几句话讲清的事,跟AI讲可能几百句都讲不清。
  • 代码本身是凝练的------特别是像Kotlin这种现代语言,但自然语言却不然。有些时候,将一些技术细节不通过代码而是自然语言来描述,可能会成几何倍数暴增文本量。你问我为什么不直接通过代码描述?那如果已经有现成代码,我岂不是直接贴上去用就成了,还找AI干嘛呢?

最终的评估结果,我判定教会AI写这个功能需要耗费的时间精力,会远远大于我自己干。甚至其失败概率还相当高。

这显然成了个悖论:

使用Vibe coding本身是为了提升开发效率,但某些情况下使用Vibe coding却会严重拖慢效率

总结一下:对于特定的具有独创性的功能,以及性能敏感的领域,如果你发现Vibe coding自己干不下去,你最好先想清楚自己是否打算写出巨细靡遗的需求文档与技术文档,并承受AI得到它们也干不了活的后果。

自己动手

这个功能的难点在于情况相当繁琐。我们先实现竖屏下的自动单页,横屏下的自动双页也是类似的原理。

总体的思想是:我们设计一个PageItem用于存储物理页码pageIndex以及处理模式itemMode,再将这些PageItem装入一个List作为供UI使用的数据源。同时,由于我们需要处理向左、向右翻页两种模式,再设计一个ConcretePageItem用于实际加载页面使用。

kotlin 复制代码
/**
 * 列表中保存的项目
 * */
data class PageItem(
    val pageIndex: Int = 0,
    val itemMode: ItemMode = ItemMode.ORIGIN,
)

enum class ItemMode {
    ORIGIN, SINGLE_1, SINGLE_2, DUAL
}

fun PageItem.toConcretePageItem(isRtl: Boolean): ConcretePageItem {
    return when (itemMode) {
        ORIGIN -> ConcretePageItem(pageIndex, ConcreteItemMode.ORIGIN)

        SINGLE_1 -> if (isRtl) ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_R)
        else ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_L)

        SINGLE_2 -> if (isRtl) ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_L)
        else ConcretePageItem(pageIndex, ConcreteItemMode.SINGLE_R)

        DUAL -> ConcretePageItem(pageIndex, ConcreteItemMode.DUAL)
    }
}

/**
 * 实际决定载入页面策略的项目
 * */
data class ConcretePageItem(
    val pageIndex: Int,
    val itemMode: ConcreteItemMode,
)

enum class ConcreteItemMode {
    ORIGIN, SINGLE_L, SINGLE_R, DUAL
}

显然,由于自动单页功能会将双页切分,也就是一个物理页面,会对应两个逻辑页面PageItem,我们的数据源List,会在进行自动单页处理后,长度增长。

比如:我们的自定义Compose初始加载左中右三个页面,每个页面的加载任务均通过ViewModel启动协程实现。这三个页面的每一个都是可被切分的物理双页,显然地,当翻页模式是向右翻页时,中间页切分出来的两个单页,其中的"右单页"应交给右侧页面显示。那么,这时候右侧页面原本启动的加载任务,其得到的结果就不应引发重组了。不过,如果三个协程完成的顺序不理想,极端情况下显然将重组三次。

接下来谈谈数据同步问题。

  • 虽然Compose并发机制的饼Google画好多年了,至今仍未实现,但三个页面的载入均通过协程实现,当它们都要对数据源List做更新操作时------比如上述例子中的情况,确实需要当心数据同步。为此,如果是在ViewModel中启动协程对数据源List做更新,注意要手动指定至Dispatcher.Main调度器,保证同步处理数据源List的更新。

  • 另外,由于无法预估用户是否会切换页面模式,因此即使是原始模式下的页码更新,也要同步更新自动单页模式下的数据源List中的列表位置,否则在模式切换后会产生位置不一致的问题。

接下来就看看效果吧:

似乎没什么问题,更详细的检查留待后续。

下一节,我们讨论一下自动双页的实现。

相关推荐
卡卡罗特AI3 天前
有了 DESIGN.md 后,大家也能写出高颜值的网站了!
ai编程·vibecoding
kunge20133 天前
1. OpenSpec 命令执行过程与 Claude Code 提示词详解
vibecoding
自传.4 天前
尚硅谷 Vibe Coding|第三章(1) Claude Code深度使用与进阶技巧 学习笔记
笔记·学习·尚硅谷·vibecoding
文艺倾年4 天前
【强化学习】数学推导专题,20W字总结(十五)
人工智能·分布式·大模型·强化学习·vibecoding
Captaincc5 天前
TRAE AI创造力大赛,正式启动!
trae·vibecoding
方白羽5 天前
一份 AGENTS.md,让 Android AI 代码规范率飙升
android·app·客户端
私人珍藏库5 天前
[Android] OldRoll复古胶片相机高级版-徕卡-哈苏-宝丽来等等
数码相机·智能手机·app·工具·软件·多功能
私人珍藏库5 天前
[Android] 红妆相机-拍照美颜图片美化工具
android·数码相机·app·软件·多功能
私人珍藏库6 天前
[Android] 精图地球-高清卫星3D街景VR地图工具
智能手机·app·工具·软件·多功能