FinTrack · Expo Brownfield Integration Guide

Guía técnica para incrustar React Native/Expo en la app nativa existente — Enfoque Integrado

● iOS (Swift/UIKit) ● Android (Kotlin) ⬡ React Native 0.78+ ◆ Expo SDK 55 ⚡ Enfoque: Integrado
Decisión de enfoque: Integrado
Análisis de criterios aplicados a FinTrack
Enfoque elegido: Integrado (Integrated)
FinTrack cumple todos los criterios para el enfoque integrado: un único equipo propietario del código nativo y RN, repositorio monorepo con ios/ y android/ en la raíz, y el equipo acepta aprender RN y añadir las herramientas al pipeline de CI/CD. El beneficio principal es hot reload + source maps desde el primer día.
Criterio Aislado Integrado FinTrack
Equipo nativo acepta tooling RN ✗ No requerido ✓ Requerido
Repositorio único / monorepo ✗ Repos separados ✓ Monorepo Monorepo
Hot reload + devtools Parcial (Metro externo) ✓ Nativo en build Prioritario
Build system estándar ✓ Ningún cambio Gradle + CocoaPods Ya usa ambos
Muchos módulos Expo Rebuild artefacto cada vez ✓ Autolinking Previsto
Un pipeline CI/CD Dos pipelines ✓ Pipeline único Requerido
Arquitectura resultante
Monorepo FinTrack con Expo SDK 55 integrado
ESTRUCTURA DEL MONOREPO
fintrack/                          ← raíz del monorepo
├── package.json                   ← workspaces: ["fintrack-rn"]
├── fintrack-rn/                   ← proyecto Expo SDK 55
│   ├── app.json
│   ├── package.json
│   ├── app/
│   │   ├── InversionesOnboarding/ ← 5 pantallas RN
│   │   └── _layout.tsx
│   └── node_modules/
├── android/                       ← app Android nativa existente
│   ├── settings.gradle            ← + RN Gradle plugin + autolinking
│   ├── build.gradle
│   └── app/
│       ├── build.gradle           ← + plugin com.facebook.react
│       ├── src/main/
│       │   └── java/com/fintrack/
│       │       ├── MainApplication.kt  ← ExpoReactHostFactory
│       │       └── InversionesActivity.kt ← ReactActivity
│       └── ...
└── ios/                           ← app iOS nativa existente
    ├── Podfile                    ← + use_expo_modules! + use_react_native!
    ├── Podfile.properties.json
    └── FinTrack.xcworkspace/      ← abrir SIEMPRE este
        └── AppDelegate.swift      ← ExpoReactNativeFactory
ℹ️
La app nativa existente (iOS + Android) permanece intacta. Solo se añaden los archivos de configuración marcados arriba. El código RN vive en fintrack-rn/ y comparte el AppRegistry.registerComponent("main", ...) entre plataformas.

Pasos de integración
Secuencia completa para FinTrack
1
Crear el proyecto Expo (SDK 55+)
Terminal — desde la raíz del monorepo
shell
# Crear el proyecto Expo dentro del monorepo
npx create-expo-app@latest fintrack-rn \
  --template default@sdk-55

# Registrar el componente con el nombre que usará el lado nativo
# En fintrack-rn/app/_layout.tsx (ya incluido en el template):
# AppRegistry.registerComponent("main", () => App)

# Instalar dependencias
cd fintrack-rn && yarn install
⚠️
SDK mínimo 55. Versiones anteriores no incluyen expo-brownfield, ExpoReactHostFactory ni ExpoReactNativeFactory. Fijar explícitamente el SDK con --template default@sdk-55.
2
Configurar el workspace en la raíz del monorepo
📄 package.json (raíz)
json
{
  "name": "fintrack-monorepo",
  "version": "1.0.0",
  "private": true,
  "workspaces": ["fintrack-rn"]
}

Después ejecutar yarn install en la raíz para que node_modules quede en el nivel workspace y tanto Gradle como CocoaPods puedan resolver las dependencias de React Native y Expo.

3
Configurar Android — Gradle
🤖 Android
⚙️ android/settings.gradle
groovy
// Registrar RN Gradle Plugin + Expo autolinking
pluginManagement {
  def reactNativeGradlePlugin = new File(
    providers.exec {
      workingDir(rootDir)
      commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
    }.standardOutput.asText.get().trim()
  ).getParentFile().absolutePath
  includeBuild(reactNativeGradlePlugin)

  // Monorepo: node_modules está en fintrack-rn/node_modules
  def expoPluginsPath = new File(
    providers.exec {
      workingDir(rootDir)
      commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
    }.standardOutput.asText.get().trim(),
    "../android/expo-gradle-plugin"
  ).absolutePath
  includeBuild(expoPluginsPath)
}

plugins {
  id("com.facebook.react.settings")
  id("expo-autolinking-settings")
}

extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
  ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
expoAutolinking.useExpoModules()
expoAutolinking.useExpoVersionCatalog()
includeBuild(expoAutolinking.reactNativeGradlePlugin)
⚙️ android/app/build.gradle — bloque react {}
groovy
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"

// Monorepo: projectRoot apunta al proyecto Expo
def projectRoot = "$rootDir/../fintrack-rn"

react {
  entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
  reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
  cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
  bundleCommand = "export:embed"
  root = file("../../") // raíz del monorepo
  autolinkLibrariesWithApp()
}
📱 MainApplication.kt
kotlin
package com.fintrack

import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory

class MainApplication : Application(), ReactApplication {

  override val reactHost: ReactHost by lazy {
    ExpoReactHostFactory.getDefaultReactHost(
      context = applicationContext,
      packageList = PackageList(this).packages
    )
  }

  override fun onCreate() {
    super.onCreate()
    loadReactNative(this)
    ApplicationLifecycleDispatcher.onApplicationCreate(this)
  }

  override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
  }
}
📱 InversionesActivity.kt — lanza el flujo RN
kotlin
package com.fintrack

import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper

/**
 * Activity que aloja el flujo InversionesOnboarding en React Native.
 * Lanzar desde HomeActivity con startActivity(Intent(this, InversionesActivity::class.java))
 */
class InversionesActivity : ReactActivity() {

  // Debe coincidir con AppRegistry.registerComponent("main", ...) en JS
  override fun getMainComponentName(): String = "main"

  override fun createReactActivityDelegate(): ReactActivityDelegate {
    return ReactActivityDelegateWrapper(
      this,
      BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
      object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) {}
    )
  }
}
📋 AndroidManifest.xml — registrar la Activity
xml
<!-- Permiso para Metro en debug -->
<uses-permission android:name="android.permission.INTERNET" />

<!-- Registrar InversionesActivity (sin ActionBar para RN) -->
<activity
  android:name=".InversionesActivity"
  android:theme="@style/Theme.AppCompat.Light.NoActionBar"
  android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
/>
⚙️ android/gradle.properties
properties
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
newArchEnabled=true
hermesEnabled=true
4
Configurar iOS — CocoaPods + Xcode
🍎 iOS
💎 ios/Podfile
ruby
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")

require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}

platform :ios, podfile_properties['ios.deploymentTarget'] || '16.4'

prepare_react_native_project!

target 'FinTrack' do # ← nombre del target Xcode existente
  use_expo_modules!

  config_command = [
    'node', '--no-warnings', '--eval',
    "require('expo/bin/autolinking')",
    'expo-modules-autolinking', 'react-native-config',
    '--json', '--platform', 'ios'
  ]
  config = use_native_modules!(config_command)

  use_react_native!(
    path: config[:reactNativePath],
    hermes_enabled: podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
    # Monorepo: ruta al proyecto Expo
    app_path: "#{Pod::Config.instance.installation_root}/../fintrack-rn",
    privacy_file_aggregation_enabled: podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
  )

  post_install do |installer|
    react_native_post_install(installer, config[:reactNativePath], mac_catalyst_enabled: false)
  end
end
📄 ios/Podfile.properties.json
json
{
  "expo.jsEngine": "hermes",
  "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
  "ios.deploymentTarget": "16.4"
}
AppDelegate.swift — integrar ExpoReactNativeFactory
swift
internal import Expo
import React
import ReactAppDependencyProvider

@main
class AppDelegate: ExpoAppDelegate {
  var window: UIWindow?
  var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
  var reactNativeFactory: RCTReactNativeFactory?

  public override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    let delegate = ReactNativeDelegate()
    let factory = ExpoReactNativeFactory(delegate: delegate)
    delegate.dependencyProvider = RCTAppDependencyProvider()
    reactNativeDelegate = delegate
    reactNativeFactory = factory

    window = UIWindow(frame: UIScreen.main.bounds)
    factory.startReactNative(
      withModuleName: "main",  // debe coincidir con AppRegistry.registerComponent
      in: window,
      launchOptions: launchOptions
    )
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
  override func bundleURL() -> URL? {
#if DEBUG
    return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else
    return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
  }
}
⚠️
3 cambios obligatorios en Xcode:
  1. ENABLE_USER_SCRIPT_SANDBOXING → No (Build Settings) — los scripts de Hermes lo necesitan.
  2. Run Script phase antes de [CP] Embed Pods Frameworks — genera main.jsbundle en release.
  3. Info.plist: UIViewControllerBasedStatusBarAppearance → NO.
Terminal — instalar pods
shell
# Desde la raíz del monorepo
EXPO_PROJECT_ROOT=$(pwd)/fintrack-rn pod install --project-directory=ios

# Abrir workspace (NO el .xcodeproj)
open ios/FinTrack.xcworkspace
5
Probar la integración
▶️ Arrancar Metro + build nativo
shell
# 1. Arrancar Metro desde el proyecto Expo
cd fintrack-rn && yarn start

# 2. En otra terminal — Android debug
cd android && ./gradlew installDebug

# 3. iOS — compilar desde Xcode o CLI
xcodebuild -workspace ios/FinTrack.xcworkspace \
           -scheme FinTrack \
           -configuration Debug \
           -destination 'platform=iOS Simulator,name=iPhone 16'

# 4. Navegar a InversionesActivity/VC desde la app nativa
# → El flujo React Native carga con hot reload activo ✓

Checklist de validación

  • Metro arranca sin errores (yarn start)
  • Android: ./gradlew installDebug compila OK
  • iOS: pod install completa sin conflictos de versión
  • InversionesActivity lanza el flujo RN desde HomeActivity
  • Hot reload funciona al editar app/InversionesOnboarding/
  • Release build embebe main.jsbundle correctamente
  • CI/CD: añadir yarn start --non-interactive + export:embed al pipeline
Integración completada. El flujo InversionesOnboarding corre en React Native/Expo dentro de la app nativa existente de FinTrack. El código JS en fintrack-rn/app/InversionesOnboarding/ se puede iterar con hot reload sin tocar el código nativo.