Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/en/apis/scheme.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ Only the following parameters are guaranteed to have an effect on **both Android
| `loading_bg_color` | `#RRGGBB` (encoded) | platform default | Loading view background color. Use 6-digit RGB only. |
| `hide_error` | `0`/`1` | `0` | Hide the error view when set to `1`. |

## Android fixed Lynx viewport

Android accepts `width` and `height` as a pair of positive physical-pixel integers. When both are
valid, Sparkling creates the Lynx view with exact preset measure specs and hosts it at exactly that
size. This preserves the historical Lynx Explorer viewport semantic; it does not change the device
screen size or density.

| Param | Type | Default | Meaning |
| --- | --- | --- | --- |
| `width` | positive integer | container width | Fixed Lynx viewport width in physical pixels. |
| `height` | positive integer | container height | Fixed Lynx viewport height in physical pixels. |

The pair is atomic. If either parameter is missing, non-integer, zero, negative, or too large for an
Android measure spec, Sparkling ignores both and keeps the default full-size behavior.

```
hybrid://lynxview_page?bundle=main.lynx.bundle&width=720&height=1280
```

### Color format (cross-platform)

Use **6-digit RGB** hex colors: `#RRGGBB` (encode `#` as `%23` in a URL).
Expand Down
5 changes: 5 additions & 0 deletions docs/en/apis/sparkling-sdk-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,13 @@ Configuration object passed to both container types.
| `scheme` | The `hybrid://...` URL to load. |
| `sparklingUIProvider` | Implements `SparklingUIProvider` for custom loading/error/toolbar views. |
| `hybridSchemeParam` | Parsed scheme parameters (auto-populated from `scheme`). |
| `lynxViewport` | Optional `SparklingLynxViewport(widthPx, heightPx)` fixed viewport in physical pixels. Programmatic configuration overrides parsed scheme dimensions. |
| `containerId` | Unique container identifier (auto-generated). |

For advanced hosts that already provide `LynxKitInitParams`, set its `lynxViewport` property. Init
params take precedence over `SparklingContext.lynxViewport`, which takes precedence over canonical
scheme `width` and `height`. All three paths require a complete positive width/height pair.

## SparklingUIProvider

Interface for customizing container UI. Applies to both full-page and embedded containers.
Expand Down
18 changes: 18 additions & 0 deletions docs/zh/apis/scheme.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ hybrid://lynxview_page?bundle=main.lynx.bundle&title=Home&title_color=%23000000&
| `loading_bg_color` | `#RRGGBB`(编码后) | 平台默认值 | 加载视图背景颜色。仅使用 6 位 RGB。 |
| `hide_error` | `0`/`1` | `0` | 设为 `1` 时隐藏错误视图。 |

## Android 固定 Lynx viewport

Android 将 `width` 和 `height` 作为一组成对的正物理像素整数处理。两者均有效时,
Sparkling 会使用精确的 preset measure spec 创建 LynxView,并以完全相同的尺寸将其挂载到
容器中。这与历史 Lynx Explorer 的 viewport 语义一致,不会修改设备屏幕尺寸或 density。

| 参数 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `width` | 正整数 | 容器宽度 | 固定 Lynx viewport 宽度,单位为物理像素。 |
| `height` | 正整数 | 容器高度 | 固定 Lynx viewport 高度,单位为物理像素。 |

这两个参数是原子配置。如果任意一个缺失、不是整数、为零、为负数,或超出 Android
measure spec 的安全范围,Sparkling 会同时忽略二者并保留默认的全尺寸行为。

```
hybrid://lynxview_page?bundle=main.lynx.bundle&width=720&height=1280
```

### 颜色格式(跨平台)

使用 **6 位 RGB** 十六进制颜色:`#RRGGBB`(在 URL 中将 `#` 编码为 `%23`)。
Expand Down
5 changes: 5 additions & 0 deletions docs/zh/apis/sparkling-sdk-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,13 @@ HybridKit.initLynxKit()
| `scheme` | 要加载的 `hybrid://...` URL。 |
| `sparklingUIProvider` | 实现 `SparklingUIProvider` 以自定义加载/错误/工具栏视图。 |
| `hybridSchemeParam` | 解析后的 scheme 参数(从 `scheme` 自动填充)。 |
| `lynxViewport` | 可选的 `SparklingLynxViewport(widthPx, heightPx)`,以物理像素指定固定 viewport。程序化配置会覆盖 scheme 中解析的尺寸。 |
| `containerId` | 唯一的容器标识符(自动生成)。 |

高级宿主如果已经使用 `LynxKitInitParams`,也可以设置其 `lynxViewport` 属性。优先级依次为:
init params、`SparklingContext.lynxViewport`、canonical scheme 的 `width` 和 `height`。
三种入口都只接受完整的正数宽高组合。

## SparklingUIProvider

自定义容器 UI 的接口。适用于全页和嵌入式容器。
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) 2026 TikTok Pte. Ltd.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
package com.tiktok.sparkling.playground

import android.view.View
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.lynx.tasm.LynxView
import com.tiktok.sparkling.Sparkling
import com.tiktok.sparkling.SparklingContext
import com.tiktok.sparkling.SparklingLifecycleDelegate
import com.tiktok.sparkling.SparklingLynxViewCreatedListener
import com.tiktok.sparkling.SparklingLynxViewport
import com.tiktok.sparkling.SparklingView
import com.tiktok.sparkling.hybridkit.base.HybridKitError
import com.tiktok.sparkling.hybridkit.base.IKitView
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

@RunWith(AndroidJUnit4::class)
class FixedLynxViewportInstrumentedTest {
@Test
fun canonicalSchemeCreatesExactPhysicalPixelLynxViewport() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val targetContext = instrumentation.targetContext
val firstScreenSeen = AtomicBoolean(false)
val loadFinishSeen = AtomicBoolean(false)
val renderLatch = CountDownLatch(2)
val loadFailure = AtomicReference<HybridKitError?>()
val bundleAvailable =
runCatching {
targetContext.assets.open(BUNDLE_NAME).close()
true
}.getOrDefault(false)
var createdLynxView: LynxView? = null
var sparklingView: SparklingView? = null

instrumentation.runOnMainSync {
val context =
SparklingContext().apply {
scheme =
"hybrid://lynxview_page?" +
"bundle=$BUNDLE_NAME&width=$VIEWPORT_WIDTH_PX&height=$VIEWPORT_HEIGHT_PX"
lynxViewCreatedListener =
SparklingLynxViewCreatedListener { lynxView ->
createdLynxView = lynxView
}
lifecycleDelegate =
object : SparklingLifecycleDelegate {
override fun onFirstScreen(view: IKitView) {
if (firstScreenSeen.compareAndSet(false, true)) {
renderLatch.countDown()
}
}

override fun onLoadFinish(view: IKitView) {
if (loadFinishSeen.compareAndSet(false, true)) {
renderLatch.countDown()
}
}

override fun onLoadFailed(
view: IKitView,
url: String,
error: HybridKitError,
) {
loadFailure.set(error)
while (renderLatch.count > 0) {
renderLatch.countDown()
}
}
}
}
val sparkling = Sparkling.build(targetContext, context)
sparkling.processSparklingContext(context)
assertEquals(
SparklingLynxViewport(VIEWPORT_WIDTH_PX, VIEWPORT_HEIGHT_PX),
context.hybridSchemeParam?.lynxViewport,
)
sparklingView = requireNotNull(sparkling.createView())
val host = FrameLayout(targetContext)
host.addView(
sparklingView,
FrameLayout.LayoutParams(HOST_WIDTH_PX, HOST_HEIGHT_PX),
)

host.measure(
View.MeasureSpec.makeMeasureSpec(HOST_WIDTH_PX, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(HOST_HEIGHT_PX, View.MeasureSpec.EXACTLY),
)
host.layout(0, 0, HOST_WIDTH_PX, HOST_HEIGHT_PX)

val lynxView = createdLynxView
assertFixedViewport(sparklingView, lynxView)
if (bundleAvailable) {
sparklingView?.loadUrl()
}
}

if (bundleAvailable) {
assertTrue(
"Timed out waiting for Lynx first-screen and load-finish callbacks",
renderLatch.await(RENDER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
)
assertNull(loadFailure.get()?.errorReason, loadFailure.get())
assertTrue("Lynx first-screen callback was not received", firstScreenSeen.get())
assertTrue("Lynx load-finish callback was not received", loadFinishSeen.get())
instrumentation.waitForIdleSync()
instrumentation.runOnMainSync {
assertFixedViewport(sparklingView, createdLynxView)
assertTrue(requireNotNull(sparklingView).isLoadSuccess())
}
}

instrumentation.runOnMainSync {
sparklingView?.release()
}
}

private fun assertFixedViewport(
sparklingView: SparklingView?,
lynxView: LynxView?,
) {
assertNotNull(lynxView)
assertSame(sparklingView, lynxView?.parent)
assertEquals(VIEWPORT_WIDTH_PX, lynxView?.layoutParams?.width)
assertEquals(VIEWPORT_HEIGHT_PX, lynxView?.layoutParams?.height)
assertEquals(VIEWPORT_WIDTH_PX, lynxView?.measuredWidth)
assertEquals(VIEWPORT_HEIGHT_PX, lynxView?.measuredHeight)
}

private companion object {
const val BUNDLE_NAME = "main.lynx.bundle"
const val VIEWPORT_WIDTH_PX = 320
const val VIEWPORT_HEIGHT_PX = 480
const val HOST_WIDTH_PX = 900
const val HOST_HEIGHT_PX = 1600
const val RENDER_TIMEOUT_SECONDS = 15L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,5 @@ class SparklingContext : HybridContext() {
var sparklingUIProvider: SparklingUIProvider? = null
var lifecycleDelegate: SparklingLifecycleDelegate? = null
var lynxViewCreatedListener: SparklingLynxViewCreatedListener? = null
var lynxViewport: SparklingLynxViewport? = null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 TikTok Pte. Ltd.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
package com.tiktok.sparkling

import android.os.Parcel
import android.os.Parcelable
import android.view.View
import com.lynx.tasm.LynxViewBuilder
import com.tiktok.sparkling.hybridkit.lynx.LynxKitInitParams
import java.io.Serializable

/**
* A fixed Lynx viewport in physical pixels.
*
* Both dimensions must be positive because Sparkling applies them as exact Lynx measure specs and
* as the hosted Lynx view's layout size.
*/
data class SparklingLynxViewport(
val widthPx: Int,
val heightPx: Int,
) : Parcelable,
Serializable {
init {
require(widthPx in 1..MAX_MEASURE_SPEC_SIZE_PX) {
"Viewport width must be between 1 and $MAX_MEASURE_SPEC_SIZE_PX"
}
require(heightPx in 1..MAX_MEASURE_SPEC_SIZE_PX) {
"Viewport height must be between 1 and $MAX_MEASURE_SPEC_SIZE_PX"
}
}

private constructor(parcel: Parcel) : this(
widthPx = parcel.readInt(),
heightPx = parcel.readInt(),
)

override fun writeToParcel(
parcel: Parcel,
flags: Int,
) {
parcel.writeInt(widthPx)
parcel.writeInt(heightPx)
}

override fun describeContents(): Int = 0

companion object {
private const val MAX_MEASURE_SPEC_SIZE_PX = 0x3fffffff

@JvmField
val CREATOR: Parcelable.Creator<SparklingLynxViewport> =
object : Parcelable.Creator<SparklingLynxViewport> {
override fun createFromParcel(parcel: Parcel): SparklingLynxViewport = SparklingLynxViewport(parcel)

override fun newArray(size: Int): Array<SparklingLynxViewport?> = arrayOfNulls(size)
}

internal fun fromRawDimensions(
width: String?,
height: String?,
): SparklingLynxViewport? {
val widthPx = width?.toIntOrNull() ?: return null
val heightPx = height?.toIntOrNull() ?: return null
if (
widthPx !in 1..MAX_MEASURE_SPEC_SIZE_PX ||
heightPx !in 1..MAX_MEASURE_SPEC_SIZE_PX
) {
return null
}
return SparklingLynxViewport(widthPx, heightPx)
}
}
}

internal fun SparklingContext.resolveLynxViewport(): SparklingLynxViewport? =
(hybridParams as? LynxKitInitParams)?.lynxViewport
?: lynxViewport
?: hybridSchemeParam?.lynxViewport

internal fun LynxViewBuilder.applyLynxViewport(viewport: SparklingLynxViewport) {
setPresetMeasuredSpec(
View.MeasureSpec.makeMeasureSpec(viewport.widthPx, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(viewport.heightPx, View.MeasureSpec.EXACTLY),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,10 @@ class SparklingView(
},
)
kitViewDelegate = kitView
addView(kitView?.realView())
val kitRealView = kitView?.realView()
kitRealView?.let {
addView(it, it.resolveLynxLayoutParams(sparklingContext))
}
observeKitViewLayout(kitView)

handleUI()
Expand Down Expand Up @@ -611,6 +614,15 @@ class SparklingView(
return Size(resolvedWidth, resolvedHeight)
}

private fun View.resolveLynxLayoutParams(sparklingContext: SparklingContext): LayoutParams {
val viewport = sparklingContext.resolveLynxViewport()
return if (viewport == null) {
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
} else {
LayoutParams(viewport.widthPx, viewport.heightPx)
}
}

private fun runOnMain(action: () -> Unit) {
if (Looper.myLooper() != Looper.getMainLooper()) {
post {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.lynx.tasm.behavior.Behavior
import com.lynx.tasm.behavior.BehaviorBundle
import com.lynx.tasm.service.LynxServiceCenter
import com.tiktok.sparkling.SparklingContext
import com.tiktok.sparkling.applyLynxViewport
import com.tiktok.sparkling.resolveLynxViewport
import com.tiktok.sparkling.hybridkit.HybridCommon
import com.tiktok.sparkling.hybridkit.HybridContext
import com.tiktok.sparkling.hybridkit.base.IHybridKitLifeCycle
Expand Down Expand Up @@ -97,6 +99,9 @@ object HybridLynxKit {
}

val viewBuilder = LynxViewBuilder()
(hybridContext as? SparklingContext)?.resolveLynxViewport()?.let { viewport ->
viewBuilder.applyLynxViewport(viewport)
}
var lynxViewRef: SimpleLynxKitView? = null
(lynxConfig?.templateProvider ?: LynxEnv.inst().templateProvider)?.let { templateProvider ->
viewBuilder.setTemplateProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.lynx.tasm.LynxViewClient
import com.lynx.tasm.TemplateBundle
import com.lynx.tasm.TemplateData
import com.lynx.tasm.behavior.Behavior
import com.tiktok.sparkling.SparklingLynxViewport
import com.tiktok.sparkling.hybridkit.base.HybridKitType
import com.tiktok.sparkling.hybridkit.base.IKitInitParam
import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam
Expand Down Expand Up @@ -47,6 +48,7 @@ open class LynxKitInitParams(
var kitBridgeService: IKitBridgeService? = null
var hybridSchemaParams: HybridSchemeParam? = null
var lynxBackgroundRuntime: LynxBackgroundRuntime? = null
var lynxViewport: SparklingLynxViewport? = null
private val globalProps = ConcurrentHashMap<String, Any>()
private var lynxClientDelegate: CopyOnWriteArrayList<LynxViewClient> = CopyOnWriteArrayList()

Expand Down
Loading
Loading