From 08b225e03deaaf55ac12e8f3f87c8092ecbaedaa Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 10 Mar 2021 10:00:03 -0800 Subject: [PATCH] Implement iOS app install deltas (#77756) --- .../lib/src/application_package.dart | 10 ++++++ .../flutter_tools/lib/src/ios/devices.dart | 3 ++ .../flutter_tools/lib/src/ios/ios_deploy.dart | 19 +++++++++++ packages/flutter_tools/lib/src/ios/mac.dart | 26 +++++++++------ .../general.shard/ios/ios_deploy_test.dart | 9 +++++ .../ios_device_start_nonprebuilt_test.dart | 33 ++++++++++++++++++- 6 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart index 1432f5b29f..72a87fe8be 100644 --- a/packages/flutter_tools/lib/src/application_package.dart +++ b/packages/flutter_tools/lib/src/application_package.dart @@ -414,6 +414,10 @@ abstract class IOSApp extends ApplicationPackage { String get simulatorBundlePath; String get deviceBundlePath; + + /// Directory used by ios-deploy to store incremental installation metadata for + /// faster second installs. + Directory get appDeltaDirectory; } class BuildableIOSApp extends IOSApp { @@ -440,6 +444,9 @@ class BuildableIOSApp extends IOSApp { @override String get deviceBundlePath => _buildAppPath('iphoneos'); + @override + Directory get appDeltaDirectory => globals.fs.directory(globals.fs.path.join(getIosBuildDirectory(), 'app-delta')); + // Xcode uses this path for the final archive bundle location, // not a top-level output directory. // Specifying `build/ios/archive/Runner` will result in `build/ios/archive/Runner.xcarchive`. @@ -468,6 +475,9 @@ class PrebuiltIOSApp extends IOSApp { final Directory bundleDir; final String bundleName; + @override + final Directory appDeltaDirectory = null; + @override String get name => bundleName; diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 2bf18d1acf..d87efa9d38 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -258,6 +258,7 @@ class IOSDevice extends Device { installationResult = await _iosDeploy.installApp( deviceId: id, bundlePath: bundle.path, + appDeltaDirectory: app.appDeltaDirectory, launchArguments: [], interfaceType: interfaceType, ); @@ -384,6 +385,7 @@ class IOSDevice extends Device { iosDeployDebugger = _iosDeploy.prepareDebuggerForLaunch( deviceId: id, bundlePath: bundle.path, + appDeltaDirectory: package.appDeltaDirectory, launchArguments: launchArguments, interfaceType: interfaceType, ); @@ -404,6 +406,7 @@ class IOSDevice extends Device { installationResult = await _iosDeploy.launchApp( deviceId: id, bundlePath: bundle.path, + appDeltaDirectory: package.appDeltaDirectory, launchArguments: launchArguments, interfaceType: interfaceType, ); diff --git a/packages/flutter_tools/lib/src/ios/ios_deploy.dart b/packages/flutter_tools/lib/src/ios/ios_deploy.dart index 9980d77741..07abf60b77 100644 --- a/packages/flutter_tools/lib/src/ios/ios_deploy.dart +++ b/packages/flutter_tools/lib/src/ios/ios_deploy.dart @@ -11,6 +11,7 @@ import 'package:process/process.dart'; import '../artifacts.dart'; import '../base/common.dart'; +import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; @@ -89,15 +90,21 @@ class IOSDeploy { Future installApp({ @required String deviceId, @required String bundlePath, + @required Directory appDeltaDirectory, @required ListlaunchArguments, @required IOSDeviceInterface interfaceType, }) async { + appDeltaDirectory?.createSync(recursive: true); final List launchCommand = [ _binaryPath, '--id', deviceId, '--bundle', bundlePath, + if (appDeltaDirectory != null) ...[ + '--app_deltas', + appDeltaDirectory.path, + ], if (interfaceType != IOSDeviceInterface.network) '--no-wifi', if (launchArguments.isNotEmpty) ...[ @@ -121,9 +128,11 @@ class IOSDeploy { IOSDeployDebugger prepareDebuggerForLaunch({ @required String deviceId, @required String bundlePath, + @required Directory appDeltaDirectory, @required List launchArguments, @required IOSDeviceInterface interfaceType, }) { + appDeltaDirectory?.createSync(recursive: true); // Interactive debug session to support sending the lldb detach command. final List launchCommand = [ 'script', @@ -135,6 +144,10 @@ class IOSDeploy { deviceId, '--bundle', bundlePath, + if (appDeltaDirectory != null) ...[ + '--app_deltas', + appDeltaDirectory.path, + ], '--debug', if (interfaceType != IOSDeviceInterface.network) '--no-wifi', @@ -157,15 +170,21 @@ class IOSDeploy { Future launchApp({ @required String deviceId, @required String bundlePath, + @required Directory appDeltaDirectory, @required List launchArguments, @required IOSDeviceInterface interfaceType, }) async { + appDeltaDirectory?.createSync(recursive: true); final List launchCommand = [ _binaryPath, '--id', deviceId, '--bundle', bundlePath, + if (appDeltaDirectory != null) ...[ + '--app_deltas', + appDeltaDirectory.path, + ], if (interfaceType != IOSDeviceInterface.network) '--no-wifi', '--justlaunch', diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 3dbf603f61..ebe4745bb2 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -426,18 +426,24 @@ Future buildXcodeProject({ targetBuildDir, buildSettings['WRAPPER_NAME'], ); - if (globals.fs.isDirectorySync(expectedOutputDirectory)) { + if (globals.fs.directory(expectedOutputDirectory).existsSync()) { // Copy app folder to a place where other tools can find it without knowing // the BuildInfo. - outputDir = expectedOutputDirectory.replaceFirst('/$configuration-', '/'); - if (globals.fs.isDirectorySync(outputDir)) { - // Previous output directory might have incompatible artifacts - // (for example, kernel binary files produced from previous run). - globals.fs.directory(outputDir).deleteSync(recursive: true); - } - copyDirectory( - globals.fs.directory(expectedOutputDirectory), - globals.fs.directory(outputDir), + outputDir = targetBuildDir.replaceFirst('/$configuration-', '/'); + globals.fs.directory(outputDir).createSync(recursive: true); + + // rsync instead of copy to maintain timestamps to support incremental + // app install deltas. Use --delete to remove incompatible artifacts + // (for example, kernel binary files produced from previous run). + await globals.processUtils.run( + [ + 'rsync', + '-av', + '--delete', + expectedOutputDirectory, + outputDir, + ], + throwOnError: true, ); } else { globals.printError('Build succeeded but the expected app at $expectedOutputDirectory not found'); diff --git a/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart index ddcf15c53d..c79344e13a 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart @@ -7,6 +7,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; @@ -23,10 +25,12 @@ import '../../src/fakes.dart'; void main () { Artifacts artifacts; String iosDeployPath; + FileSystem fileSystem; setUp(() { artifacts = Artifacts.test(); iosDeployPath = artifacts.getArtifactPath(Artifact.iosDeploy, platform: TargetPlatform.ios); + fileSystem = MemoryFileSystem.test(); }); testWithoutContext('IOSDeploy.iosDeployEnv returns path with /usr/bin first', () { @@ -50,6 +54,8 @@ void main () { '123', '--bundle', '/', + '--app_deltas', + 'app-delta', '--debug', '--args', [ @@ -62,10 +68,12 @@ void main () { stdout: '(lldb) run\nsuccess\nDid finish launching.', ), ]); + final Directory appDeltaDirectory = fileSystem.directory('app-delta'); final IOSDeploy iosDeploy = setUpIOSDeploy(processManager, artifacts: artifacts); final IOSDeployDebugger iosDeployDebugger = iosDeploy.prepareDebuggerForLaunch( deviceId: '123', bundlePath: '/', + appDeltaDirectory: appDeltaDirectory, launchArguments: ['--enable-dart-profiling'], interfaceType: IOSDeviceInterface.network, ); @@ -73,6 +81,7 @@ void main () { expect(await iosDeployDebugger.launchAndAttach(), isTrue); expect(await iosDeployDebugger.logLines.toList(), ['Did finish launching.']); expect(processManager.hasRemainingExpectations, false); + expect(appDeltaDirectory, exists); }); }); diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index cc9a912527..5398ae8ea1 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -5,6 +5,7 @@ // @dart = 2.8 import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/application_package.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; @@ -119,10 +120,22 @@ void main() { setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); processManager.addCommand(FakeCommand(command: _xattrArgs(flutterProject))); processManager.addCommand(const FakeCommand(command: kRunReleaseArgs)); - processManager.addCommand(const FakeCommand(command: [...kRunReleaseArgs, '-showBuildSettings'])); + processManager.addCommand(const FakeCommand(command: [...kRunReleaseArgs, '-showBuildSettings'], stdout: r''' + TARGET_BUILD_DIR=build/ios/Release-iphoneos + WRAPPER_NAME=My Super Awesome App.app + ''' + )); + processManager.addCommand(const FakeCommand(command: [ + 'rsync', + '-av', + '--delete', + 'build/ios/Release-iphoneos/My Super Awesome App.app', + 'build/ios/iphoneos', + ])); processManager.addCommand(FakeCommand( command: [ iosDeployPath, @@ -130,6 +143,8 @@ void main() { '123', '--bundle', 'build/ios/iphoneos/My Super Awesome App.app', + '--app_deltas', + 'build/ios/app-delta', '--no-wifi', '--justlaunch', '--args', @@ -146,6 +161,7 @@ void main() { platformArgs: {}, ); + expect(fileSystem.directory('build/ios/iphoneos'), exists); expect(launchResult.started, true); expect(processManager.hasRemainingExpectations, false); }, overrides: { @@ -169,6 +185,7 @@ void main() { setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); processManager.addCommand(FakeCommand(command: _xattrArgs(flutterProject))); processManager.addCommand(const FakeCommand(command: kRunReleaseArgs)); @@ -183,7 +200,18 @@ void main() { const FakeCommand( command: [...kRunReleaseArgs, '-showBuildSettings'], exitCode: 0, + stdout: r''' + TARGET_BUILD_DIR=build/ios/Release-iphoneos + WRAPPER_NAME=My Super Awesome App.app + ''' )); + processManager.addCommand(const FakeCommand(command: [ + 'rsync', + '-av', + '--delete', + 'build/ios/Release-iphoneos/My Super Awesome App.app', + 'build/ios/iphoneos', + ])); processManager.addCommand(FakeCommand( command: [ iosDeployPath, @@ -191,6 +219,8 @@ void main() { '123', '--bundle', 'build/ios/iphoneos/My Super Awesome App.app', + '--app_deltas', + 'build/ios/app-delta', '--no-wifi', '--justlaunch', '--args', @@ -221,6 +251,7 @@ void main() { }); expect(launchResult?.started, true); + expect(fileSystem.directory('build/ios/iphoneos'), exists); expect(processManager.hasRemainingExpectations, false); }, overrides: { ProcessManager: () => processManager,