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
41 changes: 41 additions & 0 deletions docs/en/apis/sparkling-sdk-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,44 @@ Interface for customizing container UI. Applies to both full-page and embedded c
| `getLoadingView(context)` | Returns a custom loading view, or `null` for the default. |
| `getErrorView(context)` | Returns a custom error view, or `null` for the default. |
| `getToolBar(context)` | Returns a custom `Toolbar` for `SparklingActivity` (full-page only). |

### Failed-view retry

To let a custom error view retry through Sparkling's SDK-owned load path, make
the view returned by `getErrorView(context)` implement
`SparklingRetryableErrorView`. This is an optional capability; existing
`SparklingUIProvider` implementations and plain error views remain compatible.

```java
public final class AppErrorView extends FrameLayout
implements SparklingRetryableErrorView {
private SparklingFailedViewRetry retry;

@Override
public void setSparklingRetry(SparklingFailedViewRetry retry) {
this.retry = retry;
retryButton.setOnClickListener(
ignored -> {
SparklingFailedViewRetry current = this.retry;
if (current != null && current.retry()) {
this.retry = null;
}
});
}
}
```

Sparkling registers a new single-use `SparklingFailedViewRetry` for each
current load failure. `retry()` must be called on the Android main thread and
returns `true` only when that exact current failure is atomically accepted.
Double taps, stale requests, off-main calls, and calls after container release
return `false`. An accepted retry clears the error UI and invokes Sparkling's
owned reload path without reopening the route.

If the retry fails, Sparkling returns the container to `FAIL` and registers a
new retry request. If it succeeds, the container reaches `SUCCESS`. Sparkling
also calls `setSparklingRetry(null)` when a request becomes invalid, including
on a new load, success, accepted retry, or release. Implementations must replace
their previous listener/request and must not retain the supplied `Context`.
The same contract is used by full-page `SparklingActivity` containers and
embedded `SparklingView` containers.
37 changes: 37 additions & 0 deletions docs/zh/apis/sparkling-sdk-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,40 @@ Lynx SDK 的默认策略。
| `getLoadingView(context)` | 返回自定义加载视图,返回 `null` 使用默认。 |
| `getErrorView(context)` | 返回自定义错误视图,返回 `null` 使用默认。 |
| `getToolBar(context)` | 返回 `SparklingActivity` 使用的自定义 `Toolbar`(仅全页容器)。 |

### 失败页重试

如果自定义错误页需要通过 Sparkling SDK 自己的加载链路重试,让
`getErrorView(context)` 返回的 View 实现 `SparklingRetryableErrorView`。
这是可选能力;已有 `SparklingUIProvider` 和普通错误 View 无需修改。

```java
public final class AppErrorView extends FrameLayout
implements SparklingRetryableErrorView {
private SparklingFailedViewRetry retry;

@Override
public void setSparklingRetry(SparklingFailedViewRetry retry) {
this.retry = retry;
retryButton.setOnClickListener(
ignored -> {
SparklingFailedViewRetry current = this.retry;
if (current != null && current.retry()) {
this.retry = null;
}
});
}
}
```

每次当前加载失败时,Sparkling 都会注册一个新的、只能成功使用一次的
`SparklingFailedViewRetry`。`retry()` 必须在 Android 主线程调用;只有
该请求仍对应当前失败且被原子接受时才返回 `true`。双击、过期请求、
非主线程调用以及容器释放后的调用都会返回 `false`。接受后 Sparkling
会清除错误 UI,并通过 SDK 自己的 reload 链路重试,不重新打开路由。

如果重试仍失败,容器会重新进入 `FAIL` 并注册新的 retry;如果成功,
容器会进入 `SUCCESS`。在新加载、成功、接受重试或释放等请求失效时,
Sparkling 也会调用 `setSparklingRetry(null)`。实现方必须替换之前的
监听器或请求,并且不能持有传入的 `Context`。全页
`SparklingActivity` 和嵌入式 `SparklingView` 使用相同契约。
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// 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

/**
* A single-use request to retry the current failed load through Sparkling.
*
* Returns `true` only when this request belongs to the current failed load and
* Sparkling accepts it. A request returns `false` after it has been used, after
* a newer load or failure supersedes it, after the container is released, or
* when called off the Android main thread.
*/
fun interface SparklingFailedViewRetry {
fun retry(): Boolean
}

/**
* Optional capability for a custom error view returned by [SparklingUIProvider].
*
* Sparkling supplies a new retry request for each current load failure and
* clears it with `null` when it is no longer valid. Implementations should
* replace any previously registered click listener or retry request.
*/
interface SparklingRetryableErrorView {
fun setSparklingRetry(retry: SparklingFailedViewRetry?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ class SparklingFragment : Fragment() {
sparklingView?.getKitView()?.onHide()
}

override fun onDestroyView() {
sparklingView?.release()
sparklingView = null
super.onDestroyView()
}

fun loadUrl() {
sparklingView?.loadUrl()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.tiktok.sparkling.hybridkit.base.IHybridView
import com.tiktok.sparkling.hybridkit.base.IKitView
import com.tiktok.sparkling.hybridkit.base.IPerformanceView
import com.tiktok.sparkling.hybridkit.utils.ColorUtil
import java.lang.ref.WeakReference
import org.json.JSONObject

class SparklingView(
Expand Down Expand Up @@ -77,6 +78,9 @@ class SparklingView(

private var loadStatus = IPerformanceView.LoadStatus.INIT
private var isReleased = false
private val retryStateLock = Any()
private var retryGeneration = 0L
private var activeRetryGeneration: Long? = null
private val defaultErrorText = "Oops, something went wrong!"
private val kitLayoutChangeListener =
View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
Expand Down Expand Up @@ -144,6 +148,7 @@ class SparklingView(
if (isReleased) {
return
}
invalidateFailedViewRetry()
loadStatus = IPerformanceView.LoadStatus.LOADING
runOnMain {
errorView?.visibility = GONE
Expand Down Expand Up @@ -249,6 +254,7 @@ class SparklingView(
return
}
loadStatus = IPerformanceView.LoadStatus.SUCCESS
invalidateFailedViewRetry()
runOnMain {
removeLoadingView()
errorView?.visibility = GONE
Expand All @@ -261,6 +267,7 @@ class SparklingView(
if (isReleased) {
return
}
invalidateFailedViewRetry()
loadStatus = IPerformanceView.LoadStatus.LOADING
runOnMain {
errorView?.visibility = GONE
Expand Down Expand Up @@ -401,7 +408,13 @@ class SparklingView(

override fun release() {
if (isReleased) return
isReleased = true
synchronized(retryStateLock) {
if (isReleased) return
isReleased = true
retryGeneration++
activeRetryGeneration = null
}
clearFailedViewRetry()
observedKitRealView?.removeOnLayoutChangeListener(kitLayoutChangeListener)
observedKitRealView = null
kitViewDelegate?.destroy(true)
Expand Down Expand Up @@ -506,7 +519,11 @@ class SparklingView(
if (isReleased) {
return
}
if (view !== kitViewDelegate) {
return
}
loadStatus = IPerformanceView.LoadStatus.FAIL
registerFailedViewRetry(view)
updateErrorMessage(url, hybridKitError.errorReason)
runOnMain {
removeLoadingView()
Expand All @@ -515,6 +532,68 @@ class SparklingView(
sparklingContext?.lifecycleDelegate?.onLoadFailed(view, url, hybridKitError)
}

private fun registerFailedViewRetry(view: IKitView) {
val generation =
synchronized(retryStateLock) {
retryGeneration++
activeRetryGeneration = retryGeneration
retryGeneration
}
val owner = WeakReference(this)
val retry = SparklingFailedViewRetry { owner.get()?.retryFailedLoad(generation) ?: false }
runOnMain {
val isCurrent =
synchronized(retryStateLock) {
!isReleased &&
activeRetryGeneration == generation &&
loadStatus == IPerformanceView.LoadStatus.FAIL &&
kitViewDelegate === view
}
if (isCurrent) {
(errorView as? SparklingRetryableErrorView)?.setSparklingRetry(retry)
}
}
}

private fun retryFailedLoad(generation: Long): Boolean {
if (Looper.myLooper() != Looper.getMainLooper()) {
return false
}
val kitView =
synchronized(retryStateLock) {
if (
isReleased ||
activeRetryGeneration != generation ||
loadStatus != IPerformanceView.LoadStatus.FAIL
) {
return false
}
val currentKitView = kitViewDelegate ?: return false
activeRetryGeneration = null
currentKitView
}
loadStatus = IPerformanceView.LoadStatus.LOADING
(errorView as? SparklingRetryableErrorView)?.setSparklingRetry(null)
errorView?.visibility = GONE
showLoadingView()
kitView.reload()
return true
}

private fun invalidateFailedViewRetry() {
synchronized(retryStateLock) {
retryGeneration++
activeRetryGeneration = null
}
clearFailedViewRetry()
}

private fun clearFailedViewRetry() {
runOnMain {
(errorView as? SparklingRetryableErrorView)?.setSparklingRetry(null)
}
}

private fun removeLoadingView() {
if (!disableAutoRemoveLoading) {
loadingView?.visibility = GONE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class SimpleLynxKitView :
var rawUrl: String? = null
var lynxKitInitParams: LynxKitInitParams? = null
private var hasDestroyed = false
private val simpleLynxViewClient: SimpleLynxViewClient

constructor(
context: Context,
Expand All @@ -47,7 +48,8 @@ class SimpleLynxKitView :
) : super(context, builder) {
this.hybridContext = hybridContext
this.lynxKitLifeCycle = lifeCycle
addLynxViewClient(SimpleLynxViewClient(this, this.lynxKitLifeCycle))
simpleLynxViewClient = SimpleLynxViewClient(this, this.lynxKitLifeCycle)
addLynxViewClient(simpleLynxViewClient)
KitViewManager.addKitView(this)
rawUrl = hybridContext.hybridSchemeParam?.bundle
}
Expand All @@ -71,6 +73,7 @@ class SimpleLynxKitView :
}
rawUrl = uri
runCatching {
simpleLynxViewClient.beginLoad(uri)
this.renderTemplateUrl(uri, hybridContext.initData())
updateGlobalProps(GlobalPropsUtils.instance.getGlobalProps(hybridContext.containerId))
}.onFailure {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class SimpleLynxViewClient(

override fun onPageStart(url: String?) {
super.onPageStart(url)
beginLoad(url)
}

internal fun beginLoad(url: String?) {
uri = url?.toUri()
loadFinished = false
loadFailed = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// 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 static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;

import android.content.Context;
import android.view.View;
import androidx.appcompat.widget.Toolbar;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;

@RunWith(RobolectricTestRunner.class)
@Config(sdk = 33, packageName = "com.tiktok.sparkling")
public class SparklingFailedViewRetryJavaApiTest {
@Test
public void javaCanImplementRetryableErrorView() {
Context context = RuntimeEnvironment.getApplication();
JavaRetryableErrorView errorView = new JavaRetryableErrorView(context);
SparklingFailedViewRetry retry = () -> false;

errorView.setSparklingRetry(retry);

assertSame(retry, errorView.retry);
assertFalse(errorView.retry.retry());
errorView.setSparklingRetry(null);
assertNull(errorView.retry);
}

@Test
public void existingJavaUiProviderNeedsNoNewMethod() {
Context context = RuntimeEnvironment.getApplication();
SparklingUIProvider provider =
new SparklingUIProvider() {
@Override
public View getLoadingView(Context context) {
return new View(context);
}

@Override
public View getErrorView(Context context) {
return new View(context);
}

@Override
public Toolbar getToolBar(Context context) {
return null;
}
};

assertFalse(provider.getErrorView(context) instanceof SparklingRetryableErrorView);
}

private static final class JavaRetryableErrorView extends View
implements SparklingRetryableErrorView {
private SparklingFailedViewRetry retry;

JavaRetryableErrorView(Context context) {
super(context);
}

@Override
public void setSparklingRetry(SparklingFailedViewRetry retry) {
this.retry = retry;
}
}
}
Loading
Loading