fix: 尝试安卓优化

This commit is contained in:
2026-08-02 21:41:43 +08:00
parent 19627b72fb
commit c4ce8d2116
3 changed files with 112 additions and 56 deletions

View File

@@ -30,6 +30,7 @@
<!-- 一大五小固定 6 槽占位 + 串行填内容大图 decode 后再挂小图 --> <!-- 一大五小固定 6 槽占位 + 串行填内容大图 decode 后再挂小图 -->
<div <div
v-else-if="page.type === 'one-large-five-small'" v-else-if="page.type === 'one-large-five-small'"
ref="multiRootRef"
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20" class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20"
> >
<div <div
@@ -73,8 +74,8 @@
</div> </div>
</div> </div>
<!-- 九宫格串行挂载 + immediate由父级 activate 触发 --> <!-- 九宫格串行挂载 + immediate由父级 activate 触发iOS 自观测 -->
<div v-else-if="page.type === 'nine-grid'" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6"> <div v-else-if="page.type === 'nine-grid'" ref="multiRootRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<div <div
v-for="(img, imgIdx) in (page.images || [])" v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx" :key="imgIdx"
@@ -176,8 +177,19 @@ const OLFS_STEP_MS = 300
const OLFS_SETTLE_MS = 320 const OLFS_SETTLE_MS = 320
const OLFS_DECODE_TIMEOUT_MS = 1200 const OLFS_DECODE_TIMEOUT_MS = 1200
/** 瀑布流/拍立得仍自观测;九宫格/一大五小由父级 activate+停滚驱动 */ /** 瀑布流/拍立得仍自观测;九宫格/一大五小由父级 activate+停滚驱动
const SEQUENTIAL_SELF_TYPES = new Set(['masonry', 'polaroid']) * iOS 性能足够,跳过停滚,这两种类型也走自观测(进视口自动挂载) */
const isIOSClient = (() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
const platform = navigator.platform || ''
if (/iPhone|iPad|iPod/i.test(ua)) return true
if (platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1) return true
return false
})()
const SEQUENTIAL_SELF_TYPES = new Set(
isIOSClient ? ['masonry', 'polaroid', 'nine-grid', 'one-large-five-small'] : ['masonry', 'polaroid']
)
const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small']) const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small'])
const props = defineProps({ const props = defineProps({
@@ -238,46 +250,25 @@ const preloadNineGridImages = () => {
} }
} }
/** 安卓专项:把当前 page 的所有图都预热(不创建 DOM仅触发浏览器缓存下载 /** 注:曾尝试给安卓在 onMounted 阶段后台预热所有图,
* 让浏览器在空闲时下载+解码,避免滑到时才下载造成卡顿 * 实测反而更卡顿(下载集中在画廊接近视口时)。已移除
* iOS 不预热,保留原串行体验。 */ * 若需预热,应只针对单张且错开更长时间间隔,且仅在用户即将到达时触发。 */
const isAndroidClient = (() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
return /Android/i.test(ua) && !/CrOS/i.test(ua)
})()
let androidPreloadStarted = false
const preloadAllForAndroid = () => {
if (!isAndroidClient || androidPreloadStarted) return
const urls = [
props.page?.img1,
props.page?.img2,
...(props.page?.images || []),
].filter(Boolean)
if (!urls.length) return
androidPreloadStarted = true
// 错开下载,避免同时抢占首屏网络
urls.forEach((url, i) => {
setTimeout(() => {
const img = new Image()
img.decoding = 'async'
img.src = url
}, 800 + i * 120)
})
}
const runNineSequential = async () => { const runNineSequential = async () => {
const total = (props.page.images || []).length const total = (props.page.images || []).length
console.log('[PG] runNineSequential start, total=', total)
const { cancelled } = await seq.run({ const { cancelled } = await seq.run({
total, total,
stepMs: NINE_STEP_MS, stepMs: NINE_STEP_MS,
settleMs: NINE_SETTLE_MS, settleMs: NINE_SETTLE_MS,
setCount: (n) => { shownCount.value = n }, setCount: (n) => { console.log('[PG] nine setCount', n); shownCount.value = n },
}) })
console.log('[PG] runNineSequential end, cancelled=', cancelled)
if (!cancelled) emitReady() if (!cancelled) emitReady()
} }
const showNineInstant = async () => { const showNineInstant = async () => {
console.log('[PG] showNineInstant, count=', (props.page.images || []).length)
shownCount.value = (props.page.images || []).length shownCount.value = (props.page.images || []).length
await nextTick() await nextTick()
emitReady() emitReady()
@@ -287,6 +278,7 @@ const showNineInstant = async () => {
const runOlfsSequential = async () => { const runOlfsSequential = async () => {
const token = ++olfsToken const token = ++olfsToken
const total = Math.min(6, (props.page.images || []).length) const total = Math.min(6, (props.page.images || []).length)
console.log('[PG] runOlfsSequential start, total=', total, 'token=', token)
if (!total) { if (!total) {
emitReady() emitReady()
return return
@@ -294,24 +286,27 @@ const runOlfsSequential = async () => {
shownCount.value = 0 shownCount.value = 0
await nextTick() await nextTick()
if (token !== olfsToken) return if (token !== olfsToken) { console.log('[PG] olfs cancelled at start'); return }
await sleep(OLFS_STEP_MS) await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return if (token !== olfsToken) { console.log('[PG] olfs cancelled before 1'); return }
shownCount.value = 1 shownCount.value = 1
await nextTick() await nextTick()
console.log('[PG] olfs shownCount=1, olfsLargeImgRef=', !!olfsLargeImgRef.value)
await waitImgReady(olfsLargeImgRef.value, OLFS_DECODE_TIMEOUT_MS) await waitImgReady(olfsLargeImgRef.value, OLFS_DECODE_TIMEOUT_MS)
if (token !== olfsToken) return if (token !== olfsToken) { console.log('[PG] olfs cancelled after large'); return }
for (let n = 2; n <= total; n += 1) { for (let n = 2; n <= total; n += 1) {
await sleep(OLFS_STEP_MS) await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return if (token !== olfsToken) { console.log('[PG] olfs cancelled at n=', n); return }
shownCount.value = n shownCount.value = n
console.log('[PG] olfs shownCount=', n)
await nextTick() await nextTick()
} }
if (token !== olfsToken) return if (token !== olfsToken) return
await sleep(OLFS_SETTLE_MS) await sleep(OLFS_SETTLE_MS)
console.log('[PG] olfs done')
emitReady() emitReady()
} }
@@ -324,7 +319,7 @@ const runMultiSequential = async () => {
total, total,
stepMs: MULTI_STEP_MS, stepMs: MULTI_STEP_MS,
settleMs: MULTI_SETTLE_MS, settleMs: MULTI_SETTLE_MS,
setCount: (n) => { shownCount.value = n }, setCount: (n) => { console.log('[PG] multi setCount', n, 'type=', type); shownCount.value = n },
}) })
} }
@@ -333,30 +328,71 @@ const unbindMultiObserver = () => {
multiObserver = null multiObserver = null
} }
const bindMultiObserver = async () => { /** 按 page.type 调度对应的串行挂载函数iOS 上九宫格/一大五小也走此路径) */
unbindMultiObserver() const runSequentialByType = async () => {
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) return if (multiStarted) {
await nextTick() console.log('[PG] runSequentialByType skip (multiStarted)')
const el = multiRootRef.value
if (!el || typeof IntersectionObserver === 'undefined') {
// 无观察器时直接串行,避免永不出现
if (!multiStarted) runMultiSequential()
return return
} }
// 露出约 30% 再串行,避免进屏前播完 const type = props.page?.type
console.log('[PG] runSequentialByType start', { type, nineGridAnimate: props.nineGridAnimate, imgCount: (props.page?.images || []).length })
if (type === 'nine-grid') {
if (props.nineGridAnimate) await runNineSequential()
else await showNineInstant()
return
}
if (type === 'one-large-five-small') {
await runOlfsSequential()
return
}
// masonry / polaroid
await runMultiSequential()
}
const bindMultiObserver = async () => {
unbindMultiObserver()
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
console.warn('[PG] bindMultiObserver skip, type not in set:', props.page?.type, 'isIOS:', isIOSClient)
return
}
await nextTick()
const el = multiRootRef.value
console.log('[PG] bindMultiObserver', { type: props.page?.type, hasEl: !!el, imgCount: (props.page?.images || []).length, isIOS: isIOSClient })
if (!el || typeof IntersectionObserver === 'undefined') {
if (!multiStarted) {
multiStarted = true
console.log('[PG] no el/IO → runSequentialByType')
runSequentialByType()
}
return
}
// iOS 上九宫格/一大五小:跳过观察器直接串行挂载(反正不停滚,等到进视口才挂反而突兀)
if (isIOSClient && (props.page?.type === 'nine-grid' || props.page?.type === 'one-large-five-small')) {
if (!multiStarted) {
multiStarted = true
console.log('[PG] iOS direct → runSequentialByType')
runSequentialByType()
}
return
}
// masonry/polaroid 仍用观察器:露出 30% 再串行,避免进屏前播完
multiObserver = new IntersectionObserver( multiObserver = new IntersectionObserver(
(entries) => { (entries) => {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isIntersecting || entry.intersectionRatio < 0.3) continue console.log('[PG] IO entry', { isIntersecting: entry.isIntersecting, ratio: entry.intersectionRatio, type: props.page?.type })
if (!entry.isIntersecting) continue
multiObserver?.disconnect() multiObserver?.disconnect()
multiObserver = null multiObserver = null
runMultiSequential() multiStarted = true
console.log('[PG] IO triggered → runSequentialByType')
runSequentialByType()
break break
} }
}, },
{ threshold: [0.3, 0.5, 0.7], rootMargin: '0px' } { threshold: [0, 0.1, 0.2, 0.3, 0.5], rootMargin: '0px 0px -10% 0px' }
) )
multiObserver.observe(el) multiObserver.observe(el)
console.log('[PG] observer bound')
} }
watch( watch(
@@ -387,9 +423,15 @@ watch(
readyEmitted = false readyEmitted = false
if (props.page?.type === 'nine-grid') { if (props.page?.type === 'nine-grid') {
if (!props.nineGridAnimate) preloadNineGridImages() if (!props.nineGridAnimate) preloadNineGridImages()
// iOS 自观测路径:仍需绑定观察器
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) await bindMultiObserver()
return
}
if (props.page?.type === 'one-large-five-small') {
// iOS 自观测路径:仍需绑定观察器
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) await bindMultiObserver()
return return
} }
if (props.page?.type === 'one-large-five-small') return
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) { if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
await bindMultiObserver() await bindMultiObserver()
} }
@@ -405,8 +447,8 @@ watch(
) )
onMounted(() => { onMounted(() => {
console.log('[PG] onMounted', { type: props.page?.type, imgCount: (props.page?.images || []).length, isIOS: isIOSClient, inSelfSet: SEQUENTIAL_SELF_TYPES.has(props.page?.type) })
if (!props.nineGridAnimate) preloadNineGridImages() if (!props.nineGridAnimate) preloadNineGridImages()
preloadAllForAndroid()
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) bindMultiObserver() if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) bindMultiObserver()
}) })

View File

@@ -13,7 +13,19 @@ const isAndroidClient = (() => {
return /Android/i.test(ua) && !/CrOS/i.test(ua) return /Android/i.test(ua) && !/CrOS/i.test(ua)
})() })()
const isIOSClient = (() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
const platform = navigator.platform || ''
// iPhone / iPad (旧 UA) / iPod
if (/iPhone|iPad|iPod/i.test(ua)) return true
// iPadOS 13+ 伪装为桌面 Mac通过触控判断
if (platform === 'MacIntel' && typeof navigator.maxTouchPoints === 'number' && navigator.maxTouchPoints > 1) return true
return false
})()
export const isAndroid = isAndroidClient export const isAndroid = isAndroidClient
export const isIOS = isIOSClient
const OSS_HOST = 'aliyuncs.com' const OSS_HOST = 'aliyuncs.com'
const PROC_750 = 'image/resize,w_750,lfit/format,webp/q,85' const PROC_750 = 'image/resize,w_750,lfit/format,webp/q,85'

View File

@@ -628,7 +628,7 @@ import { toast } from '@/composables/useToast'
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick' import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId' import { getClientId } from '@/utils/clientId'
import { sendVisitBeacon } from '@/utils/visitBeacon' import { sendVisitBeacon } from '@/utils/visitBeacon'
import { optimizeImageUrl } from '@/composables/useAndroidOptimize' import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors' import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc' import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
import CanvasEffects from '@/components/CanvasEffects.vue' import CanvasEffects from '@/components/CanvasEffects.vue'
@@ -748,9 +748,10 @@ const likeBusy = ref(false)
let likePulseTimer = null let likePulseTimer = null
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false) const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
const albumPreviewImages = ref([]) const albumPreviewImages = ref([])
/** 九宫格 / 一大五小:进视口中线后停滚再串行加载 */ /** 九宫格 / 一大五小:进视口中线后停滚再串行加载
* iOS 性能足够,跳过停滚,让组件进视口自动挂载(保留滚动连续性) */
const HOLD_GALLERY_TYPES = new Set(['nine-grid', 'one-large-five-small']) const HOLD_GALLERY_TYPES = new Set(['nine-grid', 'one-large-five-small'])
const isHoldGalleryType = (type) => HOLD_GALLERY_TYPES.has(type) const isHoldGalleryType = (type) => !isIOS && HOLD_GALLERY_TYPES.has(type)
const galleryActivate = reactive({}) const galleryActivate = reactive({})
const galleryHold = ref(false) const galleryHold = ref(false)
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' }) const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
@@ -1047,8 +1048,9 @@ const beginGalleryGate = (pIdx) => {
} }
const onGalleryReady = (pIdx) => { const onGalleryReady = (pIdx) => {
if (!galleryDone.has(pIdx)) return // iOS 跳过 hold但仍需刷新文档高度图挂载后 scrollMax 可能变化)
refreshScrollMax() refreshScrollMax()
if (!galleryDone.has(pIdx)) return
clearTimeout(galleryRevealTimer) clearTimeout(galleryRevealTimer)
galleryRevealTimer = setTimeout(() => { galleryRevealTimer = setTimeout(() => {
galleryHold.value = false galleryHold.value = false
@@ -1066,7 +1068,7 @@ const bindGalleryHoldObserver = () => {
galleryObserver = new IntersectionObserver( galleryObserver = new IntersectionObserver(
(entries) => { (entries) => {
const vh = window.innerHeight || 1 const vh = window.innerHeight || 1
const lineY = vh * (1 - 0.75) // 从下往上 75% const lineY = vh * (1 - 0.9) // 从下往上 75%
for (const entry of entries) { for (const entry of entries) {
if (!entry.isIntersecting) continue if (!entry.isIntersecting) continue
const rect = entry.boundingClientRect const rect = entry.boundingClientRect