@bm-fe/react-native-multi-bundle 1.0.0-beta.12 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@ React Native 多 Bundle 系统 - 支持模块按需加载和独立更新
8
8
  - ✅ **模块依赖管理**:自动处理模块间的依赖关系
9
9
  - ✅ **模块状态管理**:完整的生命周期管理(idle/loading/loaded/failed)
10
10
  - ✅ **路由加载器**:`createModuleRouteLoader`,完美支持 React Navigation `getComponent` API
11
+ - ✅ **错误处理与重试**:自动错误处理,支持重试机制和自定义错误组件
11
12
  - ✅ **预加载支持**:`preloadModule` 支持关键模块预加载
12
13
  - ✅ **开发环境 Mock**:开发环境无需 Native 模块即可运行
13
14
  - ✅ **TypeScript 支持**:完整的类型定义
@@ -79,6 +80,32 @@ export const createHomeScreen = createModuleRouteLoader('home', 'Home');
79
80
  <Stack.Screen name="Home" getComponent={createHomeScreen} />
80
81
  ```
81
82
 
83
+ ### 3. 自定义错误处理(可选)
84
+
85
+ ```typescript
86
+ import { createModuleLoader, type ErrorFallbackProps } from '@bm-fe/react-native-multi-bundle';
87
+
88
+ // 自定义错误组件
89
+ function MyErrorFallback({ moduleId, error, onRetry }: ErrorFallbackProps) {
90
+ return (
91
+ <View>
92
+ <Text>模块 {moduleId} 加载失败</Text>
93
+ <Button title="重试" onPress={onRetry} />
94
+ </View>
95
+ );
96
+ }
97
+
98
+ // 使用自定义错误组件
99
+ const createHomeScreen = createModuleLoader(
100
+ 'home',
101
+ (exports) => exports.routes.Home,
102
+ {
103
+ ErrorFallback: MyErrorFallback,
104
+ onError: (error) => console.error('加载失败:', error),
105
+ }
106
+ );
107
+ ```
108
+
82
109
  ## 完整集成指南
83
110
 
84
111
  详细的集成步骤请参考:[INTEGRATION.md](./INTEGRATION.md)
@@ -16,10 +16,8 @@ import kotlinx.coroutines.withContext
16
16
  import org.json.JSONObject
17
17
  import java.io.BufferedReader
18
18
  import java.io.File
19
- import java.io.FileOutputStream
20
19
  import java.io.FileReader
21
20
  import java.io.IOException
22
- import java.io.InputStream
23
21
  import java.io.InputStreamReader
24
22
 
25
23
  /**
@@ -27,10 +25,11 @@ import java.io.InputStreamReader
27
25
  *
28
26
  * 新架构下通过反射获取 ReactInstance,然后调用 loadJSBundle 方法
29
27
  * 注意:ReactInstance 是 internal 类,必须完全通过反射操作
30
- *
31
- * Bundle 加载优先级:
32
- * 1. CodePush 目录(热更新后的 bundle)
33
- * 2. Assets(APK 内置的 bundle)
28
+ *
29
+ * Bundle 加载优先级(解决 CodePush 增量更新只包含变化 bundle 的问题):
30
+ * 1. CodePush 当前 package hash 目录(最新热更新的 bundle)
31
+ * 2. CodePush 历史 package hash 目录(按时间从新到旧遍历)
32
+ * 3. Assets(APK 内置的 bundle,最终 fallback)
34
33
  */
35
34
  @ReactModule(name = ModuleLoaderModule.NAME)
36
35
  class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
@@ -38,7 +37,7 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
38
37
  companion object {
39
38
  const val NAME = "ModuleLoader"
40
39
  private const val TAG = "ModuleLoaderModule"
41
-
40
+
42
41
  // CodePush 相关常量
43
42
  private const val CODE_PUSH_FOLDER_PREFIX = "CodePush"
44
43
  private const val STATUS_FILE = "codepush.json"
@@ -102,7 +101,7 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
102
101
 
103
102
  Log.d(TAG, "Using CatalystInstance (same JS Runtime guaranteed)")
104
103
  try {
105
- // 1️⃣ 优先从 CodePush 目录加载
104
+ // 1️⃣ 优先从 CodePush 目录加载(当前包 → 历史包)
106
105
  val codePushBundlePath = getCodePushBundlePath(bundlePath)
107
106
  if (codePushBundlePath != null) {
108
107
  Log.d(TAG, "Loading bundle from CodePush: $codePushBundlePath")
@@ -111,8 +110,8 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
111
110
  promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
112
111
  return true
113
112
  }
114
-
115
- // 2️⃣ 回退到 assets 加载
113
+
114
+ // 2️⃣ 回退到 assets 加载(APK 内置)
116
115
  val assetPath = "assets://$bundlePath"
117
116
  Log.d(TAG, "Loading bundle from assets: $assetPath")
118
117
  catalystInstance.loadScriptFromAssets(reactContext.assets, assetPath, false)
@@ -144,19 +143,19 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
144
143
  }
145
144
 
146
145
  Log.d(TAG, "Using ReactHostImpl (new architecture)")
147
-
148
- // 1️⃣ 优先从 CodePush 目录加载
146
+
147
+ // 1️⃣ 优先从 CodePush 目录加载(当前包 → 历史包)
149
148
  val codePushBundlePath = getCodePushBundlePath(bundlePath)
150
149
  val bundleLoader = if (codePushBundlePath != null) {
151
150
  Log.d(TAG, "Loading bundle from CodePush: $codePushBundlePath")
152
151
  JSBundleLoader.createFileLoader(codePushBundlePath)
153
152
  } else {
154
- // 2️⃣ 回退到 assets 加载
153
+ // 2️⃣ 回退到 assets 加载(APK 内置)
155
154
  val assetUrl = "assets://$bundlePath"
156
155
  Log.d(TAG, "Loading bundle from assets: $assetUrl")
157
156
  JSBundleLoader.createAssetLoader(reactContext, assetUrl, false)
158
157
  }
159
-
158
+
160
159
  loadBundleViaReflection(reactHost, bundleLoader, bundleId, promise)
161
160
  return true
162
161
  }
@@ -228,21 +227,21 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
228
227
  return try {
229
228
  val statusFilePath = "${getCodePushPath()}/$STATUS_FILE"
230
229
  val statusFile = File(statusFilePath)
231
-
230
+
232
231
  if (!statusFile.exists()) {
233
232
  Log.d(TAG, "CodePush status file not found: $statusFilePath")
234
233
  return null
235
234
  }
236
-
235
+
237
236
  val content = statusFile.readText()
238
237
  val json = JSONObject(content)
239
238
  val packageHash = json.optString(CURRENT_PACKAGE_KEY, null)
240
-
239
+
241
240
  if (packageHash.isNullOrEmpty()) {
242
241
  Log.d(TAG, "No current package hash in CodePush status")
243
242
  return null
244
243
  }
245
-
244
+
246
245
  Log.d(TAG, "Current CodePush package hash: $packageHash")
247
246
  packageHash
248
247
  } catch (e: Exception) {
@@ -261,70 +260,125 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
261
260
 
262
261
  /**
263
262
  * 从 CodePush 目录查找子 bundle 文件
264
- *
265
- * 查找顺序:
266
- * 1. <packageFolder>/modules/<bundleFileName>
267
- * 2. <packageFolder>/<bundlePath>
268
- * 3. <packageFolder>/<bundleFileName>
269
- *
263
+ *
264
+ * 查找优先级(解决增量更新只包含变化 bundle 的问题):
265
+ * 1. 当前 package hash 目录
266
+ * 2. 按时间从新到旧遍历其他 package hash 目录
267
+ * 3. 如果都找不到,返回 null,由调用方从 assets:// 加载
268
+ *
269
+ * 每个目录内的查找顺序:
270
+ * 1. <folder>/modules/<bundleFileName>
271
+ * 2. <folder>/<bundlePath>
272
+ * 3. <folder>/<bundleFileName>
273
+ *
270
274
  * @param bundlePath 原始 bundle 路径,如 "modules/home.jsbundle"
271
- * @return 完整的文件路径,如果不存在返回 null
275
+ * @return 完整的文件路径,如果不存在返回 null(调用方会从 assets:// 加载)
272
276
  */
273
277
  private fun getCodePushBundlePath(bundlePath: String): String? {
274
- val packageHash = getCurrentPackageHash() ?: return null
275
- val packageFolder = getPackageFolderPath(packageHash)
276
-
277
- // 检查包目录是否存在
278
- val packageDir = File(packageFolder)
279
- if (!packageDir.exists()) {
280
- Log.d(TAG, "CodePush package folder not found: $packageFolder")
278
+ val codePushDir = File(getCodePushPath())
279
+
280
+ // CodePush 目录不存在,直接返回 null(调用方会从 assets:// 加载)
281
+ if (!codePushDir.exists()) {
282
+ Log.d(TAG, "CodePush directory not found, will load from assets")
281
283
  return null
282
284
  }
283
-
284
- // 提取文件名
285
+
285
286
  val bundleFileName = File(bundlePath).name
286
-
287
- // 查找路径列表
287
+ val currentPackageHash = getCurrentPackageHash()
288
+
289
+ // 1️⃣ 优先从当前 package hash 目录查找
290
+ if (currentPackageHash != null) {
291
+ val currentPackageFolder = getPackageFolderPath(currentPackageHash)
292
+ val foundPath = findBundleInFolder(currentPackageFolder, bundlePath, bundleFileName)
293
+ if (foundPath != null) {
294
+ Log.d(TAG, "Found bundle in current package: $foundPath")
295
+ return foundPath
296
+ }
297
+ Log.d(TAG, "Bundle not found in current package ($currentPackageHash), searching other packages...")
298
+ }
299
+
300
+ // 2️⃣ 按时间从新到旧遍历其他 package hash 目录
301
+ val otherPackageFolders = getPackageFoldersSortedByTime(codePushDir, currentPackageHash)
302
+ for (packageFolder in otherPackageFolders) {
303
+ val foundPath = findBundleInFolder(packageFolder.absolutePath, bundlePath, bundleFileName)
304
+ if (foundPath != null) {
305
+ Log.d(TAG, "Found bundle in previous package (${packageFolder.name}): $foundPath")
306
+ return foundPath
307
+ }
308
+ }
309
+
310
+ Log.d(TAG, "Bundle not found in CodePush directories, will load from assets: $bundlePath")
311
+ return null
312
+ }
313
+
314
+ /**
315
+ * 在指定文件夹中查找 bundle 文件
316
+ *
317
+ * 查找顺序:
318
+ * 1. <folder>/modules/<bundleFileName>
319
+ * 2. <folder>/<bundlePath>
320
+ * 3. <folder>/<bundleFileName>
321
+ */
322
+ private fun findBundleInFolder(folderPath: String, bundlePath: String, bundleFileName: String): String? {
323
+ val folder = File(folderPath)
324
+ if (!folder.exists() || !folder.isDirectory) {
325
+ return null
326
+ }
327
+
288
328
  val searchPaths = listOf(
289
- // 1. <packageFolder>/modules/<bundleFileName>
290
- "$packageFolder/modules/$bundleFileName",
291
- // 2. <packageFolder>/<bundlePath> (完整相对路径)
292
- "$packageFolder/$bundlePath",
293
- // 3. <packageFolder>/<bundleFileName> (仅文件名)
294
- "$packageFolder/$bundleFileName"
329
+ "$folderPath/modules/$bundleFileName",
330
+ "$folderPath/$bundlePath",
331
+ "$folderPath/$bundleFileName"
295
332
  )
296
-
333
+
297
334
  for (path in searchPaths) {
298
335
  val file = File(path)
299
336
  if (file.exists() && file.isFile) {
300
- Log.d(TAG, "Found bundle in CodePush: $path")
301
337
  return path
302
338
  }
303
339
  }
304
-
305
- Log.d(TAG, "Bundle not found in CodePush package, searched: $searchPaths")
340
+
306
341
  return null
307
342
  }
308
343
 
344
+ /**
345
+ * 获取所有 package 目录,按修改时间从新到旧排序
346
+ * 排除当前 package、assets-base 和 codepush.json 等非 package 目录/文件
347
+ */
348
+ private fun getPackageFoldersSortedByTime(codePushDir: File, currentPackageHash: String?): List<File> {
349
+ val excludedNames = setOf(STATUS_FILE, "download", "unzipped")
350
+
351
+ return codePushDir.listFiles()
352
+ ?.filter { file ->
353
+ file.isDirectory &&
354
+ file.name != currentPackageHash &&
355
+ !excludedNames.contains(file.name)
356
+ }
357
+ ?.sortedByDescending { it.lastModified() }
358
+ ?: emptyList()
359
+ }
360
+
309
361
  // ==================== Bundle Manifest Support ====================
310
362
 
311
363
  /**
312
364
  * 获取当前 bundle manifest 文件路径
313
- *
365
+ *
314
366
  * 查找优先级:
315
367
  * 1. CodePush 当前包目录(热更新后的 manifest)
316
- * 2. assets-base 目录(初始复制的 manifest)
368
+ * 2. 返回 null(调用方应使用 getCurrentBundleManifestContent 获取内容)
369
+ *
370
+ * 注意:如果需要从 assets 读取,请使用 getCurrentBundleManifestContent
317
371
  */
318
372
  @ReactMethod
319
373
  fun getCurrentBundleManifest(promise: Promise) {
320
374
  try {
321
- // 1️⃣ 优先从 CodePush 当前包目录获取
375
+ // 优先从 CodePush 当前包目录获取
322
376
  val packageHash = getCurrentPackageHash()
323
377
  if (packageHash != null) {
324
378
  val packageFolder = getPackageFolderPath(packageHash)
325
379
  val codePushManifestPath = "$packageFolder/bundle-manifest.json"
326
380
  val codePushManifestFile = File(codePushManifestPath)
327
-
381
+
328
382
  if (codePushManifestFile.exists()) {
329
383
  Log.d(TAG, "Found manifest in CodePush package: $codePushManifestPath")
330
384
  promise.resolve(codePushManifestPath)
@@ -332,22 +386,8 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
332
386
  }
333
387
  }
334
388
 
335
- // 2️⃣ 确保 assets-base 目录已初始化
336
- ensureCodePushDirectoryInitialized()
337
-
338
- // 从 assets-base 目录获取 manifest
339
- val codePushDir = getCodePushPath()
340
- val assetsBaseManifestPath = "$codePushDir/assets-base/bundle-manifest.json"
341
- val assetsBaseManifestFile = File(assetsBaseManifestPath)
342
-
343
- if (assetsBaseManifestFile.exists()) {
344
- Log.d(TAG, "Found manifest in assets-base: $assetsBaseManifestPath")
345
- promise.resolve(assetsBaseManifestPath)
346
- return
347
- }
348
-
349
- // 如果都不存在,返回 null
350
- Log.w(TAG, "Bundle manifest not found in any location")
389
+ // CodePush 中找不到,返回 null(调用方应使用 getCurrentBundleManifestContent)
390
+ Log.d(TAG, "Manifest not in CodePush, use getCurrentBundleManifestContent to read from assets")
351
391
  promise.resolve(null)
352
392
  } catch (e: Exception) {
353
393
  Log.e(TAG, "Failed to get current bundle manifest: ${e.message}")
@@ -357,10 +397,10 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
357
397
 
358
398
  /**
359
399
  * 获取当前 bundle manifest 文件内容
360
- *
400
+ *
361
401
  * 查找优先级:
362
402
  * 1. CodePush 当前包目录(热更新后的 manifest)
363
- * 2. assets-base 目录(初始复制的 manifest
403
+ * 2. APK 内置 assets(最终 fallback
364
404
  */
365
405
  @ReactMethod
366
406
  fun getCurrentBundleManifestContent(promise: Promise) {
@@ -373,7 +413,7 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
373
413
  val packageFolder = getPackageFolderPath(packageHash)
374
414
  val codePushManifestPath = "$packageFolder/bundle-manifest.json"
375
415
  val codePushManifestFile = File(codePushManifestPath)
376
-
416
+
377
417
  if (codePushManifestFile.exists()) {
378
418
  val content = readFileContent(codePushManifestFile)
379
419
  if (content != null) {
@@ -382,25 +422,15 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
382
422
  }
383
423
  }
384
424
  }
385
-
386
- // 2️⃣ 确保 assets-base 目录已初始化
387
- ensureCodePushDirectoryInitialized()
388
-
389
- // assets-base 目录读取 manifest
390
- val codePushDir = getCodePushPath()
391
- val assetsBaseManifestPath = "$codePushDir/assets-base/bundle-manifest.json"
392
- val assetsBaseManifestFile = File(assetsBaseManifestPath)
393
-
394
- if (assetsBaseManifestFile.exists()) {
395
- val content = readFileContent(assetsBaseManifestFile)
396
- if (content != null) {
397
- Log.d(TAG, "✓ Read bundle manifest from assets-base directory")
398
- return@withContext content
399
- }
425
+
426
+ // 2️⃣ APK 内置 assets 读取
427
+ val assetsContent = readAssetContent("bundle-manifest.json")
428
+ if (assetsContent != null) {
429
+ Log.d(TAG, "✓ Read bundle manifest from assets")
430
+ return@withContext assetsContent
400
431
  }
401
432
 
402
- // 如果都不存在,返回 null
403
- Log.w(TAG, "Bundle manifest content not found in any location")
433
+ Log.w(TAG, "Bundle manifest not found in any location")
404
434
  null
405
435
  }
406
436
  promise.resolve(manifestContent)
@@ -412,116 +442,25 @@ class ModuleLoaderModule(private val reactContext: ReactApplicationContext) : Re
412
442
  }
413
443
 
414
444
  /**
415
- * 确保 CodePush 目录已从 assets 初始化
445
+ * assets 读取文件内容
416
446
  */
417
- private fun ensureCodePushDirectoryInitialized() {
418
- try {
419
- val codePushDir = "${reactContext.filesDir.absolutePath}/CodePush"
420
- val assetsBaseDir = "$codePushDir/assets-base"
421
-
422
- // 检查 assets-base 目录是否有 bundle-manifest.json 和 modules
423
- val manifestFile = File("$assetsBaseDir/bundle-manifest.json")
424
- val modulesDir = File("$assetsBaseDir/modules")
425
-
426
- // 如果任一不存在,从 assets 复制
427
- if (!manifestFile.exists() || !modulesDir.exists()) {
428
- Log.d(TAG, "Initializing CodePush directory from assets...")
429
- copyAssetsToCodePushDirectory(codePushDir)
430
- }
431
- } catch (e: Exception) {
432
- Log.e(TAG, "Failed to initialize CodePush directory: ${e.message}")
433
- }
434
- }
435
-
436
- /**
437
- * 从 assets 复制 bundle-manifest.json 和 modules 目录到 CodePush 目录
438
- */
439
- private fun copyAssetsToCodePushDirectory(codePushDir: String) {
440
- try {
441
- // 创建 assets-base 目录
442
- val defaultPackageDir = "$codePushDir/assets-base"
443
- val defaultPackageDirFile = File(defaultPackageDir)
444
- if (!defaultPackageDirFile.exists()) {
445
- defaultPackageDirFile.mkdirs()
446
- }
447
-
448
- // 1. 复制 bundle-manifest.json
449
- try {
450
- val manifestAssetPath = "bundle-manifest.json"
451
- val manifestInput = reactContext.assets.open(manifestAssetPath)
452
- val manifestOutput = File("$defaultPackageDir/bundle-manifest.json")
453
- copyInputStreamToFile(manifestInput, manifestOutput)
454
- Log.d(TAG, "✓ Copied bundle-manifest.json to CodePush directory")
455
- } catch (e: IOException) {
456
- Log.w(TAG, "Warning: Could not copy bundle-manifest.json: ${e.message}")
457
- }
458
-
459
- // 2. 复制 modules 目录
460
- try {
461
- val modulesAssetPath = "modules"
462
- val modulesOutputDir = File("$defaultPackageDir/modules")
463
- if (!modulesOutputDir.exists()) {
464
- modulesOutputDir.mkdirs()
465
- }
466
-
467
- copyAssetFolder(modulesAssetPath, modulesOutputDir.absolutePath)
468
- Log.d(TAG, "✓ Copied modules directory to CodePush directory")
469
- } catch (e: IOException) {
470
- Log.w(TAG, "Warning: Could not copy modules directory: ${e.message}")
471
- }
472
-
473
- } catch (e: Exception) {
474
- Log.e(TAG, "Failed to copy assets to CodePush directory: ${e.message}")
475
- }
476
- }
477
-
478
- /**
479
- * 递归复制 asset 文件夹
480
- */
481
- @Throws(IOException::class)
482
- private fun copyAssetFolder(assetPath: String, targetPath: String) {
483
- val assets = reactContext.assets.list(assetPath)
484
- if (assets.isNullOrEmpty()) {
485
- // 这是一个文件,不是目录
486
- val input = reactContext.assets.open(assetPath)
487
- val output = File(targetPath)
488
- copyInputStreamToFile(input, output)
489
- } else {
490
- // 这是一个目录
491
- val dir = File(targetPath)
492
- if (!dir.exists()) {
493
- dir.mkdirs()
494
- }
495
-
496
- for (asset in assets) {
497
- val subAssetPath = "$assetPath/$asset"
498
- val subTargetPath = "$targetPath/$asset"
499
- copyAssetFolder(subAssetPath, subTargetPath)
500
- }
501
- }
502
- }
503
-
504
- /**
505
- * 将 InputStream 复制到文件
506
- */
507
- @Throws(IOException::class)
508
- private fun copyInputStreamToFile(input: InputStream, output: File) {
509
- var outputStream: FileOutputStream? = null
510
- try {
511
- outputStream = FileOutputStream(output)
512
- val buffer = ByteArray(4096)
513
- var bytesRead: Int
514
- while (input.read(buffer).also { bytesRead = it } != -1) {
515
- outputStream.write(buffer, 0, bytesRead)
447
+ private fun readAssetContent(assetPath: String): String? {
448
+ var reader: BufferedReader? = null
449
+ return try {
450
+ val inputStream = reactContext.assets.open(assetPath)
451
+ reader = BufferedReader(InputStreamReader(inputStream))
452
+ val content = StringBuilder()
453
+ var line: String?
454
+ while (reader.readLine().also { line = it } != null) {
455
+ content.append(line).append("\n")
516
456
  }
457
+ content.toString()
458
+ } catch (e: IOException) {
459
+ Log.e(TAG, "Failed to read asset: $assetPath - ${e.message}")
460
+ null
517
461
  } finally {
518
462
  try {
519
- input.close()
520
- } catch (e: IOException) {
521
- // Ignore
522
- }
523
- try {
524
- outputStream?.close()
463
+ reader?.close()
525
464
  } catch (e: IOException) {
526
465
  // Ignore
527
466
  }
@@ -18,6 +18,11 @@ RCT_EXPORT_MODULE(ModuleLoader);
18
18
  // Bundle manifest file name
19
19
  static NSString *const BundleManifestFileName = @"bundle-manifest.json";
20
20
 
21
+ // CodePush 相关常量
22
+ static NSString *const CodePushFolderPrefix = @"CodePush";
23
+ static NSString *const StatusFileName = @"codepush.json";
24
+ static NSString *const CurrentPackageKey = @"currentPackage";
25
+
21
26
  #pragma mark - Bundle Path Resolution
22
27
 
23
28
  /**
@@ -84,7 +89,212 @@ static NSString *const BundleManifestFileName = @"bundle-manifest.json";
84
89
  return nil;
85
90
  }
86
91
 
92
+ #pragma mark - CodePush Bundle Path Resolution
93
+
94
+ /**
95
+ * 获取 CodePush 基础目录
96
+ * 路径: <ApplicationSupport>/CodePush/ 或 <Documents>/CodePush/
97
+ */
98
+ - (NSString *)getCodePushPath {
99
+ // 优先使用 Application Support 目录
100
+ NSString *appSupportPath = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) firstObject];
101
+ NSString *codePushPath = [appSupportPath stringByAppendingPathComponent:CodePushFolderPrefix];
102
+
103
+ if ([[NSFileManager defaultManager] fileExistsAtPath:codePushPath]) {
104
+ return codePushPath;
105
+ }
106
+
107
+ // 回退到 Documents 目录
108
+ NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
109
+ codePushPath = [documentsPath stringByAppendingPathComponent:CodePushFolderPrefix];
110
+
111
+ if ([[NSFileManager defaultManager] fileExistsAtPath:codePushPath]) {
112
+ return codePushPath;
113
+ }
114
+
115
+ return nil;
116
+ }
117
+
118
+ /**
119
+ * 获取当前 CodePush 包的 hash
120
+ * 从 <CodePushPath>/codepush.json 读取 currentPackage 字段
121
+ */
122
+ - (NSString *)getCurrentPackageHash {
123
+ NSString *codePushPath = [self getCodePushPath];
124
+ if (!codePushPath) {
125
+ return nil;
126
+ }
127
+
128
+ NSString *statusFilePath = [codePushPath stringByAppendingPathComponent:StatusFileName];
129
+
130
+ if (![[NSFileManager defaultManager] fileExistsAtPath:statusFilePath]) {
131
+ RCTLogInfo(@"[ModuleLoader] CodePush status file not found: %@", statusFilePath);
132
+ return nil;
133
+ }
134
+
135
+ @try {
136
+ NSData *data = [NSData dataWithContentsOfFile:statusFilePath];
137
+ if (!data) {
138
+ return nil;
139
+ }
140
+
141
+ NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
142
+ NSString *packageHash = json[CurrentPackageKey];
143
+
144
+ if (packageHash && packageHash.length > 0) {
145
+ RCTLogInfo(@"[ModuleLoader] Current CodePush package hash: %@", packageHash);
146
+ return packageHash;
147
+ }
148
+ } @catch (NSException *exception) {
149
+ RCTLogWarn(@"[ModuleLoader] Error reading CodePush status: %@", exception.reason);
150
+ }
151
+
152
+ return nil;
153
+ }
154
+
155
+ /**
156
+ * 获取 CodePush 包目录路径
157
+ * 路径: <CodePushPath>/<packageHash>/
158
+ */
159
+ - (NSString *)getPackageFolderPath:(NSString *)packageHash {
160
+ NSString *codePushPath = [self getCodePushPath];
161
+ if (!codePushPath) {
162
+ return nil;
163
+ }
164
+ return [codePushPath stringByAppendingPathComponent:packageHash];
165
+ }
166
+
167
+ /**
168
+ * 在指定文件夹中查找 bundle 文件
169
+ *
170
+ * 查找顺序:
171
+ * 1. <folder>/modules/<bundleFileName>
172
+ * 2. <folder>/<bundlePath>
173
+ * 3. <folder>/<bundleFileName>
174
+ */
175
+ - (NSString *)findBundleInFolder:(NSString *)folderPath
176
+ bundlePath:(NSString *)bundlePath
177
+ bundleFileName:(NSString *)bundleFileName {
178
+ NSFileManager *fileManager = [NSFileManager defaultManager];
179
+
180
+ BOOL isDirectory;
181
+ if (![fileManager fileExistsAtPath:folderPath isDirectory:&isDirectory] || !isDirectory) {
182
+ return nil;
183
+ }
184
+
185
+ NSArray<NSString *> *searchPaths = @[
186
+ [folderPath stringByAppendingPathComponent:[@"modules/" stringByAppendingString:bundleFileName]],
187
+ [folderPath stringByAppendingPathComponent:bundlePath],
188
+ [folderPath stringByAppendingPathComponent:bundleFileName]
189
+ ];
190
+
191
+ for (NSString *path in searchPaths) {
192
+ if ([fileManager fileExistsAtPath:path]) {
193
+ return path;
194
+ }
195
+ }
196
+
197
+ return nil;
198
+ }
199
+
200
+ /**
201
+ * 获取所有 package 目录,按修改时间从新到旧排序
202
+ * 排除当前 package 和非 package 目录/文件
203
+ */
204
+ - (NSArray<NSString *> *)getPackageFoldersSortedByTime:(NSString *)codePushDir
205
+ currentPackageHash:(NSString *)currentPackageHash {
206
+ NSFileManager *fileManager = [NSFileManager defaultManager];
207
+ NSSet *excludedNames = [NSSet setWithArray:@[StatusFileName, @"download", @"unzipped"]];
208
+
209
+ NSError *error;
210
+ NSArray<NSString *> *contents = [fileManager contentsOfDirectoryAtPath:codePushDir error:&error];
211
+ if (error || !contents) {
212
+ return @[];
213
+ }
214
+
215
+ NSMutableArray<NSDictionary *> *packageFolders = [NSMutableArray array];
216
+
217
+ for (NSString *name in contents) {
218
+ // 排除非 package 目录
219
+ if ([excludedNames containsObject:name]) {
220
+ continue;
221
+ }
222
+ if (currentPackageHash && [name isEqualToString:currentPackageHash]) {
223
+ continue;
224
+ }
225
+
226
+ NSString *fullPath = [codePushDir stringByAppendingPathComponent:name];
227
+ BOOL isDirectory;
228
+ if ([fileManager fileExistsAtPath:fullPath isDirectory:&isDirectory] && isDirectory) {
229
+ NSDictionary *attrs = [fileManager attributesOfItemAtPath:fullPath error:nil];
230
+ NSDate *modDate = attrs[NSFileModificationDate] ?: [NSDate distantPast];
231
+ [packageFolders addObject:@{@"path": fullPath, @"date": modDate}];
232
+ }
233
+ }
234
+
235
+ // 按修改时间从新到旧排序
236
+ [packageFolders sortUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
237
+ return [b[@"date"] compare:a[@"date"]];
238
+ }];
239
+
240
+ NSMutableArray<NSString *> *sortedPaths = [NSMutableArray array];
241
+ for (NSDictionary *folder in packageFolders) {
242
+ [sortedPaths addObject:folder[@"path"]];
243
+ }
244
+
245
+ return sortedPaths;
246
+ }
247
+
248
+ /**
249
+ * 从 CodePush 目录查找子 bundle 文件
250
+ *
251
+ * 查找优先级(解决增量更新只包含变化 bundle 的问题):
252
+ * 1. 当前 package hash 目录
253
+ * 2. 按时间从新到旧遍历其他 package hash 目录
254
+ * 3. 如果都找不到,返回 nil,由调用方从内置目录加载
255
+ *
256
+ * @param bundlePath 原始 bundle 路径,如 "modules/home.jsbundle"
257
+ * @return 完整的文件路径,如果不存在返回 nil
258
+ */
87
259
  - (NSString *)getCodePushBundlePath:(NSString *)bundlePath {
260
+ NSString *codePushDir = [self getCodePushPath];
261
+
262
+ // CodePush 目录不存在,直接返回 nil
263
+ if (!codePushDir) {
264
+ RCTLogInfo(@"[ModuleLoader] CodePush directory not found, will load from MainBundle");
265
+ return nil;
266
+ }
267
+
268
+ NSString *bundleFileName = [bundlePath lastPathComponent];
269
+ NSString *currentPackageHash = [self getCurrentPackageHash];
270
+
271
+ // 1️⃣ 优先从当前 package hash 目录查找
272
+ if (currentPackageHash) {
273
+ NSString *currentPackageFolder = [self getPackageFolderPath:currentPackageHash];
274
+ NSString *foundPath = [self findBundleInFolder:currentPackageFolder
275
+ bundlePath:bundlePath
276
+ bundleFileName:bundleFileName];
277
+ if (foundPath) {
278
+ RCTLogInfo(@"[ModuleLoader] Found bundle in current package: %@", foundPath);
279
+ return foundPath;
280
+ }
281
+ RCTLogInfo(@"[ModuleLoader] Bundle not found in current package (%@), searching other packages...", currentPackageHash);
282
+ }
283
+
284
+ // 2️⃣ 按时间从新到旧遍历其他 package hash 目录
285
+ NSArray<NSString *> *otherPackageFolders = [self getPackageFoldersSortedByTime:codePushDir
286
+ currentPackageHash:currentPackageHash];
287
+ for (NSString *packageFolder in otherPackageFolders) {
288
+ NSString *foundPath = [self findBundleInFolder:packageFolder
289
+ bundlePath:bundlePath
290
+ bundleFileName:bundleFileName];
291
+ if (foundPath) {
292
+ RCTLogInfo(@"[ModuleLoader] Found bundle in previous package (%@): %@", [packageFolder lastPathComponent], foundPath);
293
+ return foundPath;
294
+ }
295
+ }
296
+
297
+ RCTLogInfo(@"[ModuleLoader] Bundle not found in CodePush directories, will load from MainBundle: %@", bundlePath);
88
298
  return nil;
89
299
  }
90
300
 
@@ -330,14 +540,6 @@ RCT_EXPORT_METHOD(loadBusinessBundle:(NSString *)bundleId
330
540
 
331
541
  #pragma mark - Bundle Manifest Methods
332
542
 
333
- + (NSString *)getApplicationSupportDirectory {
334
- return [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) objectAtIndex:0];
335
- }
336
-
337
- + (NSString *)bundleAssetsPath {
338
- return [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"assets"];
339
- }
340
-
341
543
  - (NSString *)readFileContent:(NSString *)filePath {
342
544
  @try {
343
545
  NSError *error;
@@ -359,7 +561,19 @@ RCT_EXPORT_METHOD(getCurrentBundleManifest:(RCTPromiseResolveBlock)resolve
359
561
  @try {
360
562
  NSFileManager *fileManager = [NSFileManager defaultManager];
361
563
 
362
- // 1. MainBundle/Bundles 目录
564
+ // 1️⃣ 优先从 CodePush 当前包目录获取
565
+ NSString *packageHash = [self getCurrentPackageHash];
566
+ if (packageHash) {
567
+ NSString *packageFolder = [self getPackageFolderPath:packageHash];
568
+ NSString *codePushManifestPath = [packageFolder stringByAppendingPathComponent:BundleManifestFileName];
569
+ if ([fileManager fileExistsAtPath:codePushManifestPath]) {
570
+ RCTLogInfo(@"[ModuleLoader] Found manifest in CodePush package: %@", codePushManifestPath);
571
+ resolve(codePushManifestPath);
572
+ return;
573
+ }
574
+ }
575
+
576
+ // 2️⃣ MainBundle/Bundles 目录(内置)
363
577
  NSString *bundlesPath = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"Bundles"];
364
578
  NSString *bundlesManifestPath = [bundlesPath stringByAppendingPathComponent:BundleManifestFileName];
365
579
  if ([fileManager fileExistsAtPath:bundlesManifestPath]) {
@@ -368,33 +582,7 @@ RCT_EXPORT_METHOD(getCurrentBundleManifest:(RCTPromiseResolveBlock)resolve
368
582
  return;
369
583
  }
370
584
 
371
- // 2. Documents 目录
372
- NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
373
- if (documentsPath) {
374
- NSString *documentsManifestPath = [documentsPath stringByAppendingPathComponent:BundleManifestFileName];
375
- if ([fileManager fileExistsAtPath:documentsManifestPath]) {
376
- resolve(documentsManifestPath);
377
- return;
378
- }
379
- }
380
-
381
- // 3. Application Support 目录
382
- NSString *appSupportPath = [[self class] getApplicationSupportDirectory];
383
- NSString *appSupportManifestPath = [appSupportPath stringByAppendingPathComponent:BundleManifestFileName];
384
- if ([fileManager fileExistsAtPath:appSupportManifestPath]) {
385
- resolve(appSupportManifestPath);
386
- return;
387
- }
388
-
389
- // 4. MainBundle assets 目录
390
- NSString *assetsPath = [[self class] bundleAssetsPath];
391
- NSString *assetsManifestPath = [assetsPath stringByAppendingPathComponent:BundleManifestFileName];
392
- if ([fileManager fileExistsAtPath:assetsManifestPath]) {
393
- resolve(assetsManifestPath);
394
- return;
395
- }
396
-
397
- // 5. MainBundle 根目录
585
+ // 3️⃣ MainBundle 根目录
398
586
  NSString *mainBundleManifestPath = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:BundleManifestFileName];
399
587
  if ([fileManager fileExistsAtPath:mainBundleManifestPath]) {
400
588
  resolve(mainBundleManifestPath);
@@ -417,58 +605,39 @@ RCT_EXPORT_METHOD(getCurrentBundleManifestContent:(RCTPromiseResolveBlock)resolv
417
605
  NSFileManager *fileManager = [NSFileManager defaultManager];
418
606
  NSString *manifestContent = nil;
419
607
 
420
- // 1. MainBundle/Bundles 目录
421
- NSString *bundlesPath = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"Bundles"];
422
- NSString *bundlesManifestPath = [bundlesPath stringByAppendingPathComponent:BundleManifestFileName];
423
- if ([fileManager fileExistsAtPath:bundlesManifestPath]) {
424
- manifestContent = [self readFileContent:bundlesManifestPath];
425
- if (manifestContent) {
426
- RCTLogInfo(@"[ModuleLoader] Read manifest from Bundles");
427
- dispatch_async(dispatch_get_main_queue(), ^{ resolve(manifestContent); });
428
- return;
429
- }
430
- }
431
-
432
- // 2. Documents 目录
433
- NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
434
- if (documentsPath) {
435
- NSString *documentsManifestPath = [documentsPath stringByAppendingPathComponent:BundleManifestFileName];
436
- if ([fileManager fileExistsAtPath:documentsManifestPath]) {
437
- manifestContent = [self readFileContent:documentsManifestPath];
608
+ // 1️⃣ 优先从 CodePush 当前包目录读取
609
+ NSString *packageHash = [self getCurrentPackageHash];
610
+ if (packageHash) {
611
+ NSString *packageFolder = [self getPackageFolderPath:packageHash];
612
+ NSString *codePushManifestPath = [packageFolder stringByAppendingPathComponent:BundleManifestFileName];
613
+ if ([fileManager fileExistsAtPath:codePushManifestPath]) {
614
+ manifestContent = [self readFileContent:codePushManifestPath];
438
615
  if (manifestContent) {
616
+ RCTLogInfo(@"[ModuleLoader] ✓ Read manifest from CodePush package");
439
617
  dispatch_async(dispatch_get_main_queue(), ^{ resolve(manifestContent); });
440
618
  return;
441
619
  }
442
620
  }
443
621
  }
444
622
 
445
- // 3. Application Support 目录
446
- NSString *appSupportPath = [[self class] getApplicationSupportDirectory];
447
- NSString *appSupportManifestPath = [appSupportPath stringByAppendingPathComponent:BundleManifestFileName];
448
- if ([fileManager fileExistsAtPath:appSupportManifestPath]) {
449
- manifestContent = [self readFileContent:appSupportManifestPath];
450
- if (manifestContent) {
451
- dispatch_async(dispatch_get_main_queue(), ^{ resolve(manifestContent); });
452
- return;
453
- }
454
- }
455
-
456
- // 4. MainBundle assets 目录
457
- NSString *assetsPath = [[self class] bundleAssetsPath];
458
- NSString *assetsManifestPath = [assetsPath stringByAppendingPathComponent:BundleManifestFileName];
459
- if ([fileManager fileExistsAtPath:assetsManifestPath]) {
460
- manifestContent = [self readFileContent:assetsManifestPath];
623
+ // 2️⃣ MainBundle/Bundles 目录(内置)
624
+ NSString *bundlesPath = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"Bundles"];
625
+ NSString *bundlesManifestPath = [bundlesPath stringByAppendingPathComponent:BundleManifestFileName];
626
+ if ([fileManager fileExistsAtPath:bundlesManifestPath]) {
627
+ manifestContent = [self readFileContent:bundlesManifestPath];
461
628
  if (manifestContent) {
629
+ RCTLogInfo(@"[ModuleLoader] ✓ Read manifest from Bundles");
462
630
  dispatch_async(dispatch_get_main_queue(), ^{ resolve(manifestContent); });
463
631
  return;
464
632
  }
465
633
  }
466
634
 
467
- // 5. MainBundle 根目录
635
+ // 3️⃣ MainBundle 根目录
468
636
  NSString *mainBundleManifestPath = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:BundleManifestFileName];
469
637
  if ([fileManager fileExistsAtPath:mainBundleManifestPath]) {
470
638
  manifestContent = [self readFileContent:mainBundleManifestPath];
471
639
  if (manifestContent) {
640
+ RCTLogInfo(@"[ModuleLoader] ✓ Read manifest from MainBundle");
472
641
  dispatch_async(dispatch_get_main_queue(), ^{ resolve(manifestContent); });
473
642
  return;
474
643
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bm-fe/react-native-multi-bundle",
3
- "version": "1.0.0-beta.12",
3
+ "version": "1.0.0-beta.13",
4
4
  "description": "React Native 多 Bundle 系统 - 支持模块按需加载和独立更新",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ export {
18
18
  } from './multi-bundle/config';
19
19
 
20
20
  // 模块加载器
21
- export { createModuleLoader, type CreateModuleLoaderOptions } from './multi-bundle/createModuleLoader';
21
+ export { createModuleLoader, type CreateModuleLoaderOptions, type ErrorFallbackProps } from './multi-bundle/createModuleLoader';
22
22
  export { createModuleRouteLoader } from './multi-bundle/createModuleRouteLoader';
23
23
 
24
24
  // 模块预加载
@@ -251,6 +251,8 @@ const createHomeScreen = createModuleRouteLoader('home', 'Home');
251
251
  <Stack.Screen name="Home" getComponent={createHomeScreen} />
252
252
  ```
253
253
 
254
+ **注意**:`createModuleRouteLoader` 内部使用 `createModuleLoader`,因此也支持错误重试功能。如果模块加载失败,用户可以通过错误页面上的重试按钮重新加载模块。
255
+
254
256
  ### createModuleLoader
255
257
 
256
258
  创建通用的模块加载器函数(用于自定义组件提取逻辑)。
@@ -263,14 +265,60 @@ function createModuleLoader(
263
265
  ): () => React.ComponentType<any>
264
266
  ```
265
267
 
268
+ **CreateModuleLoaderOptions**:
269
+ ```typescript
270
+ interface CreateModuleLoaderOptions {
271
+ onError?: (error: Error) => void; // 错误处理回调
272
+ ErrorFallback?: React.ComponentType<ErrorFallbackProps>; // 自定义错误组件
273
+ }
274
+ ```
275
+
276
+ **ErrorFallbackProps**:
277
+ ```typescript
278
+ interface ErrorFallbackProps {
279
+ moduleId: string; // 模块 ID
280
+ error: Error | null; // 错误对象
281
+ onRetry: () => void; // 重试回调(点击重试按钮会触发重新加载)
282
+ }
283
+ ```
284
+
266
285
  **使用示例**:
267
286
  ```typescript
287
+ // 基础用法
268
288
  const createCustomScreen = createModuleLoader(
269
289
  'my-module',
270
290
  (exports) => exports.components.MyComponent
271
291
  );
292
+
293
+ // 使用自定义错误组件
294
+ import { ErrorFallbackProps } from '@bm-fe/react-native-multi-bundle';
295
+
296
+ function MyCustomErrorFallback({ moduleId, error, onRetry }: ErrorFallbackProps) {
297
+ return (
298
+ <View>
299
+ <Text>模块 {moduleId} 加载失败</Text>
300
+ <Button title="重试" onPress={onRetry} />
301
+ </View>
302
+ );
303
+ }
304
+
305
+ const createCustomScreen = createModuleLoader(
306
+ 'my-module',
307
+ (exports) => exports.components.MyComponent,
308
+ {
309
+ ErrorFallback: MyCustomErrorFallback,
310
+ onError: (error) => {
311
+ console.error('模块加载失败:', error);
312
+ },
313
+ }
314
+ );
272
315
  ```
273
316
 
317
+ **特性**:
318
+ - ✅ 自动重试:点击重试按钮会触发模块重新加载
319
+ - ✅ 自定义错误 UI:支持传入自定义错误组件
320
+ - ✅ 错误回调:可通过 `onError` 监听加载失败事件
321
+
274
322
 
275
323
  ### registerModuleRoute
276
324
 
@@ -11,8 +11,15 @@ import { ModuleErrorFallback } from './ModuleErrorFallback';
11
11
 
12
12
  type ModuleState = 'idle' | 'loading' | 'success' | 'error';
13
13
 
14
- interface CreateModuleLoaderOptions {
14
+ export interface ErrorFallbackProps {
15
+ moduleId: string;
16
+ error: Error | null;
17
+ onRetry: () => void;
18
+ }
19
+
20
+ export interface CreateModuleLoaderOptions {
15
21
  onError?: (error: Error) => void;
22
+ ErrorFallback?: React.ComponentType<ErrorFallbackProps>;
16
23
  }
17
24
 
18
25
  /**
@@ -33,6 +40,7 @@ export function createModuleLoader(
33
40
  const [InnerComponent, setInnerComponent] =
34
41
  useState<React.ComponentType<any> | null>(null);
35
42
  const [error, setError] = useState<Error | null>(null);
43
+ const [retryCount, setRetryCount] = useState(0);
36
44
 
37
45
  useEffect(() => {
38
46
  let isMounted = true;
@@ -63,16 +71,18 @@ export function createModuleLoader(
63
71
  return () => {
64
72
  isMounted = false;
65
73
  };
66
- }, [moduleId]);
74
+ }, [moduleId, retryCount]);
67
75
 
68
76
  if (state === 'error') {
77
+ const FallbackComponent = options.ErrorFallback || ModuleErrorFallback;
69
78
  return (
70
- <ModuleErrorFallback
79
+ <FallbackComponent
71
80
  moduleId={moduleId}
72
81
  error={error}
73
82
  onRetry={() => {
74
83
  setState('idle');
75
84
  setError(null);
85
+ setRetryCount((c) => c + 1);
76
86
  }}
77
87
  />
78
88
  );