diff --git a/packages/flutter_tools/lib/src/base/analyze_size.dart b/packages/flutter_tools/lib/src/base/analyze_size.dart new file mode 100644 index 00000000000..aa60c53765b --- /dev/null +++ b/packages/flutter_tools/lib/src/base/analyze_size.dart @@ -0,0 +1,370 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:vm_snapshot_analysis/treemap.dart'; + +import '../base/file_system.dart'; +import '../convert.dart'; +import 'logger.dart'; +import 'process.dart'; +import 'terminal.dart'; + +/// A class to analyze APK and AOT snapshot and generate a breakdown of the data. +class SizeAnalyzer { + SizeAnalyzer({ + @required this.fileSystem, + @required this.logger, + @required this.processUtils, + }); + + final FileSystem fileSystem; + final Logger logger; + final ProcessUtils processUtils; + + static const String aotSnapshotFileName = 'aot-snapshot.json'; + + static const int tableWidth = 80; + + /// Analyzes [apk] and [aotSnapshot] to output a [Map] object that includes + /// the breakdown of the both files, where the breakdown of [aotSnapshot] is placed + /// under 'lib/arm64-v8a/libapp.so'. + /// + /// The [aotSnapshot] can be either instruction sizes snapshot or v8 snapshot. + Future> analyzeApkSizeAndAotSnapshot({ + @required File apk, + @required File aotSnapshot, + }) async { + logger.printStatus('▒' * tableWidth); + _printEntitySize( + '${apk.basename} (total compressed)', + byteSize: apk.lengthSync(), + level: 0, + showColor: false, + ); + logger.printStatus('━' * tableWidth); + final Directory tempApkContent = fileSystem.systemTempDirectory.createTempSync('flutter_tools.'); + // TODO(peterdjlee): Implement a way to unzip the APK for Windows. See issue #62603. + String unzipOut; + try { + // TODO(peterdjlee): Use zipinfo instead of unzip. + unzipOut = (await processUtils.run([ + 'unzip', + '-o', + '-v', + apk.path, + '-d', + tempApkContent.path + ])).stdout; + } on Exception catch (e) { + logger.printError(e.toString()); + } finally { + // We just want the the stdout printout. We don't need the files. + tempApkContent.deleteSync(recursive: true); + } + + final _SymbolNode apkAnalysisRoot = _parseUnzipFile(unzipOut); + + // Convert an AOT snapshot file into a map. + final Map processedAotSnapshotJson = treemapFromJson( + json.decode(aotSnapshot.readAsStringSync()), + ); + final _SymbolNode aotSnapshotJsonRoot = _parseAotSnapshot(processedAotSnapshotJson); + + for (final _SymbolNode firstLevelPath in apkAnalysisRoot.children) { + _printEntitySize( + firstLevelPath.name, + byteSize: firstLevelPath.byteSize, + level: 1, + ); + // Print the expansion of lib directory to show more info for libapp.so. + if (firstLevelPath.name == 'lib') { + _printLibChildrenPaths(firstLevelPath, '', aotSnapshotJsonRoot); + } + } + + logger.printStatus('▒' * tableWidth); + + Map apkAnalysisJson = apkAnalysisRoot.toJson(); + + apkAnalysisJson['type'] = 'apk'; + + // TODO(peterdjlee): Add aot snapshot for all platforms. + apkAnalysisJson = _addAotSnapshotDataToApkAnalysis( + apkAnalysisJson: apkAnalysisJson, + path: 'lib/arm64-v8a/libapp.so (Dart AOT)'.split('/'), // Pass in a list of paths by splitting with '/'. + aotSnapshotJson: processedAotSnapshotJson, + ); + + return apkAnalysisJson; + } + + + // Expression to match 'Size' column to group 1 and 'Name' column to group 2. + final RegExp _parseUnzipOutput = RegExp(r'^\s*\d+\s+[\w|:]+\s+(\d+)\s+.* (.+)$'); + + // Parse the output of unzip -v which shows the zip's contents' compressed sizes. + // Example output of unzip -v: + // Length Method Size Cmpr Date Time CRC-32 Name + // -------- ------ ------- ---- ---------- ----- -------- ---- + // 11708 Defl:N 2592 78% 00-00-1980 00:00 07733eef AndroidManifest.xml + // 1399 Defl:N 1092 22% 00-00-1980 00:00 f53d952a META-INF/CERT.RSA + // 46298 Defl:N 14530 69% 00-00-1980 00:00 17df02b8 META-INF/CERT.SF + _SymbolNode _parseUnzipFile(String unzipOut) { + final Map, int> pathsToSize = , int>{}; + + // Parse each path into pathsToSize so that the key is a list of + // path parts and the value is the size. + // For example: + // 'path/to/file' where file = 1500 => pathsToSize[['path', 'to', 'file']] = 1500 + for (final String line in const LineSplitter().convert(unzipOut)) { + final RegExpMatch match = _parseUnzipOutput.firstMatch(line); + if (match == null) { + continue; + } + const int sizeGroupIndex = 1; + const int nameGroupIndex = 2; + pathsToSize[match.group(nameGroupIndex).split('/')] = int.parse(match.group(sizeGroupIndex)); + } + + final _SymbolNode rootNode = _SymbolNode('Root'); + + _SymbolNode currentNode = rootNode; + for (final List paths in pathsToSize.keys) { + for (final String path in paths) { + _SymbolNode childWithPathAsName = currentNode.childByName(path); + + if (childWithPathAsName == null) { + childWithPathAsName = _SymbolNode(path); + if (path.endsWith('libapp.so')) { + childWithPathAsName.name += ' (Dart AOT)'; + } else if (path.endsWith('libflutter.so')) { + childWithPathAsName.name += ' (Flutter Engine)'; + } + currentNode.addChild(childWithPathAsName); + } + childWithPathAsName.addSize(pathsToSize[paths]); + currentNode = childWithPathAsName; + } + currentNode = rootNode; + } + + return rootNode; + } + + /// Prints all children paths for the lib/ directory in an APK. + /// + /// A brief summary of aot snapshot is printed under 'lib/arm64-v8a/libapp.so'. + void _printLibChildrenPaths( + _SymbolNode currentNode, + String totalPath, + _SymbolNode aotSnapshotJsonRoot, + ) { + totalPath += currentNode.name; + + if (currentNode.children.isNotEmpty && !currentNode.name.contains('libapp.so')) { + for (final _SymbolNode child in currentNode.children) { + _printLibChildrenPaths(child, '$totalPath/', aotSnapshotJsonRoot); + } + } else { + // Print total path and size if currentNode does not have any chilren. + _printEntitySize(totalPath, byteSize: currentNode.byteSize, level: 2); + + // We picked this file because arm64-v8a is likely the most popular + // architecture. ther architecture sizes should be similar. + const String libappPath = 'lib/arm64-v8a/libapp.so'; + // TODO(peterdjlee): Analyze aot size for all platforms. + if (totalPath.contains(libappPath)) { + _printAotSnapshotSummary(aotSnapshotJsonRoot); + } + } + } + + /// Go through the AOT gen snapshot size JSON and print out a collapsed summary + /// for the first package level. + void _printAotSnapshotSummary(_SymbolNode aotSnapshotRoot, {int maxDirectoriesShown = 10}) { + _printEntitySize( + 'Dart AOT symbols accounted decompressed size', + byteSize: aotSnapshotRoot.byteSize, + level: 3, + ); + + final List<_SymbolNode> sortedSymbols = aotSnapshotRoot.children.toList() + ..sort((_SymbolNode a, _SymbolNode b) => b.byteSize.compareTo(a.byteSize)); + for (final _SymbolNode node in sortedSymbols.take(maxDirectoriesShown)) { + _printEntitySize(node.name, byteSize: node.byteSize, level: 4); + } + } + + /// Adds breakdown of aot snapshot data as the children of the node at the given path. + Map _addAotSnapshotDataToApkAnalysis({ + @required Map apkAnalysisJson, + @required List path, + @required Map aotSnapshotJson, + }) { + Map currentLevel = apkAnalysisJson; + while (path.isNotEmpty) { + final List> children = currentLevel['children'] as List>; + final Map childWithPathAsName = children.firstWhere( + (Map child) => child['n'] as String == path.first, + ); + path.removeAt(0); + currentLevel = childWithPathAsName; + } + currentLevel['children'] = aotSnapshotJson['children']; + return apkAnalysisJson; + } + + /// Print an entity's name with its size on the same line. + void _printEntitySize( + String entityName, { + @required int byteSize, + @required int level, + bool showColor = true, + }) { + final bool emphasis = level <= 1; + final String formattedSize = _prettyPrintBytes(byteSize); + + TerminalColor color = TerminalColor.green; + if (formattedSize.endsWith('MB')) { + color = TerminalColor.cyan; + } else if (formattedSize.endsWith('KB')) { + color = TerminalColor.yellow; + } + + final int spaceInBetween = tableWidth - level * 2 - entityName.length - formattedSize.length; + logger.printStatus( + entityName + ' ' * spaceInBetween, + newline: false, + emphasis: emphasis, + indent: level * 2, + ); + logger.printStatus(formattedSize, color: showColor ? color : null); + } + + String _prettyPrintBytes(int numBytes) { + const int kB = 1024; + const int mB = kB * 1024; + if (numBytes < kB) { + return '$numBytes B'; + } else if (numBytes < mB) { + return '${(numBytes / kB).round()} KB'; + } else { + return '${(numBytes / mB).round()} MB'; + } + } + + _SymbolNode _parseAotSnapshot(Map aotSnapshotJson) { + final bool isLeafNode = aotSnapshotJson['children'] == null; + if (!isLeafNode) { + return _buildNodeWithChildren(aotSnapshotJson); + } else { + // TODO(peterdjlee): Investigate why there are leaf nodes with size of null. + final int byteSize = aotSnapshotJson['value'] as int; + if (byteSize == null) { + return null; + } + return _buildNode(aotSnapshotJson, byteSize); + } + } + + _SymbolNode _buildNode( + Map aotSnapshotJson, + int byteSize, { + List<_SymbolNode> children = const <_SymbolNode>[], + }) { + final String name = aotSnapshotJson['n'] as String; + final Map childrenMap = {}; + + for (final _SymbolNode child in children) { + childrenMap[child.name] = child; + } + + return _SymbolNode( + name, + byteSize: byteSize, + )..addAllChildren(children); + } + + /// Builds a node by recursively building all of its children first + /// in order to calculate the sum of its children's sizes. + _SymbolNode _buildNodeWithChildren(Map aotSnapshotJson) { + final List rawChildren = aotSnapshotJson['children'] as List; + final List<_SymbolNode> symbolNodeChildren = <_SymbolNode>[]; + int totalByteSize = 0; + + // Given a child, build its subtree. + for (final dynamic child in rawChildren) { + final _SymbolNode childTreemapNode = _parseAotSnapshot(child as Map); + symbolNodeChildren.add(childTreemapNode); + totalByteSize += childTreemapNode.byteSize; + } + + // If none of the children matched the diff tree type + if (totalByteSize == 0) { + return null; + } else { + return _buildNode( + aotSnapshotJson, + totalByteSize, + children: symbolNodeChildren, + ); + } + } +} + +/// A node class that represents a single symbol for AOT size snapshots. +class _SymbolNode { + _SymbolNode( + this.name, { + this.byteSize = 0, + }) : assert(name != null), + assert(byteSize != null), + _children = {}; + + /// The human friendly identifier for this node. + String name; + + int byteSize; + void addSize(int sizeToBeAdded) { + byteSize += sizeToBeAdded; + } + + _SymbolNode get parent => _parent; + _SymbolNode _parent; + + Iterable<_SymbolNode> get children => _children.values; + final Map _children; + + _SymbolNode childByName(String name) => _children[name]; + + _SymbolNode addChild(_SymbolNode child) { + assert(child.parent == null); + assert(!_children.containsKey(child.name), + 'Cannot add duplicate child key ${child.name}'); + + child._parent = this; + _children[child.name] = child; + return child; + } + + void addAllChildren(List<_SymbolNode> children) { + children.forEach(addChild); + } + + Map toJson() { + final Map json = { + 'n': name, + 'value': byteSize + }; + final List> childrenAsJson = >[]; + for (final _SymbolNode child in children) { + childrenAsJson.add(child.toJson()); + } + if (childrenAsJson.isNotEmpty) { + json['children'] = childrenAsJson; + } + return json; + } +} diff --git a/packages/flutter_tools/test/general.shard/base/analyze_size_test.dart b/packages/flutter_tools/test/general.shard/base/analyze_size_test.dart new file mode 100644 index 00000000000..df554e93313 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/base/analyze_size_test.dart @@ -0,0 +1,167 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/analyze_size.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/process.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +const FakeCommand unzipCommmand = FakeCommand( + command: [ + 'unzip', + '-o', + '-v', + 'test.apk', + '-d', + '/.tmp_rand0/flutter_tools.rand0' + ], + stdout: ''' +Length Method Size Cmpr Date Time CRC-32 Name +-------- ------ ------- ---- ---------- ----- -------- ---- +11708 Defl:N 2592 78% 00-00-1980 00:00 07733eef AndroidManifest.xml +1399 Defl:N 1092 22% 00-00-1980 00:00 f53d952a META-INF/CERT.RSA +46298 Defl:N 14530 69% 00-00-1980 00:00 17df02b8 META-INF/CERT.SF +46298 Defl:N 14530 69% 00-00-1980 00:00 17df02b8 lib/arm64-v8a/libapp.so +46298 Defl:N 14530 69% 00-00-1980 00:00 17df02b8 lib/arm64-v8a/libflutter.so +''', +); + +const String aotSizeOutput = '''[ + { + "l": "dart:_internal", + "c": "SubListIterable", + "n": "[Optimized] skip", + "s": 2400 + }, + { + "l": "dart:_internal", + "c": "SubListIterable", + "n": "[Optimized] new SubListIterable.", + "s": 3560 + }, + { + "l": "dart:core", + "c": "RangeError", + "n": "[Optimized] new RangeError.range", + "s": 3920 + }, + { + "l": "dart:core", + "c": "ArgumentError", + "n": "[Stub] Allocate ArgumentError", + "s": 4650 + } +] +'''; + +void main() { + MemoryFileSystem fileSystem; + BufferLogger logger; + FakeProcessManager processManager; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + logger = BufferLogger.test(); + processManager = FakeProcessManager.list([unzipCommmand]); + }); + + test('builds APK analysis correctly', () async { + final SizeAnalyzer sizeAnalyzer = SizeAnalyzer( + fileSystem: fileSystem, + logger: logger, + processUtils: ProcessUtils( + processManager: processManager, + logger: logger, + ), + ); + + final File apk = fileSystem.file('test.apk')..createSync(); + final File aotSizeJson = fileSystem.file('test.json') + ..createSync() + ..writeAsStringSync(aotSizeOutput); + final Map result = await sizeAnalyzer.analyzeApkSizeAndAotSnapshot(apk: apk, aotSnapshot: aotSizeJson); + + expect(result['type'], contains('apk')); + + final Map androidManifestMap = result['children'][0] as Map; + expect(androidManifestMap['n'], equals('AndroidManifest.xml')); + expect(androidManifestMap['value'], equals(2592)); + + final Map metaInfMap = result['children'][1] as Map; + expect(metaInfMap['n'], equals('META-INF')); + expect(metaInfMap['value'], equals(15622)); + final Map certRsaMap = metaInfMap['children'][0] as Map; + expect(certRsaMap['n'], equals('CERT.RSA')); + expect(certRsaMap['value'], equals(1092)); + final Map certSfMap = metaInfMap['children'][1] as Map; + expect(certSfMap['n'], equals('CERT.SF')); + expect(certSfMap['value'], equals(14530)); + + final Map libMap = result['children'][2] as Map; + expect(libMap['n'], equals('lib')); + expect(libMap['value'], equals(29060)); + final Map arm64Map = libMap['children'][0] as Map; + expect(arm64Map['n'], equals('arm64-v8a')); + expect(arm64Map['value'], equals(29060)); + final Map libAppMap = arm64Map['children'][0] as Map; + expect(libAppMap['n'], equals('libapp.so (Dart AOT)')); + expect(libAppMap['value'], equals(14530)); + expect(libAppMap['children'].length, equals(3)); + final Map internalMap = libAppMap['children'][0] as Map; + final Map skipMap = internalMap['children'][0] as Map; + expect(skipMap['n'], 'skip'); + expect(skipMap['value'], 2400); + final Map subListIterableMap = internalMap['children'][1] as Map; + expect(subListIterableMap['n'], 'new SubListIterable.'); + expect(subListIterableMap['value'], 3560); + final Map coreMap = libAppMap['children'][1] as Map; + final Map rangeErrorMap = coreMap['children'][0] as Map; + expect(rangeErrorMap['n'], 'new RangeError.range'); + expect(rangeErrorMap['value'], 3920); + final Map stubsMap = libAppMap['children'][2] as Map; + final Map allocateMap = stubsMap['children'][0] as Map; + expect(allocateMap['n'], 'Allocate ArgumentError'); + expect(allocateMap['value'], 4650); + final Map libFlutterMap = arm64Map['children'][1] as Map; + expect(libFlutterMap['n'], equals('libflutter.so (Flutter Engine)')); + expect(libFlutterMap['value'], equals(14530)); + }); + + test('outputs summary to command line correctly', () async { + final SizeAnalyzer sizeAnalyzer = SizeAnalyzer( + fileSystem: fileSystem, + logger: logger, + processUtils: ProcessUtils( + processManager: processManager, + logger: logger, + ), + ); + + final File apk = fileSystem.file('test.apk')..createSync(); + final File aotSizeJson = fileSystem.file('test.json') + ..createSync() + ..writeAsStringSync(aotSizeOutput); + await sizeAnalyzer.analyzeApkSizeAndAotSnapshot(apk: apk, aotSnapshot: aotSizeJson); + + final List stdout = logger.statusText.split('\n'); + expect( + stdout, + containsAll([ + ' AndroidManifest.xml 3 KB', + ' META-INF 15 KB', + ' lib 28 KB', + ' lib/arm64-v8a/libapp.so (Dart AOT) 14 KB', + ' Dart AOT symbols accounted decompressed size 14 KB', + ' dart:_internal/SubListIterable 6 KB', + ' @stubs/allocation-stubs/dart:core/ArgumentError 5 KB', + ' dart:core/RangeError 4 KB', + ' lib/arm64-v8a/libflutter.so (Flutter Engine) 14 KB', + ]), + ); + }); +}