| #!/usr/bin/env python3 |
| # Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file |
| # for details. All rights reserved. Use of this source code is governed by a |
| # BSD-style license that can be found in the LICENSE file. |
| |
| # Script for checking impact of a change by comparing the sizes of generated |
| # classes in an apk. |
| |
| import glob |
| import optparse |
| import os |
| import shutil |
| import sys |
| import threading |
| import time |
| import zipfile |
| |
| import toolhelper |
| import utils |
| |
| USAGE = """%prog [options] app1 app2 |
| NOTE: This only makes sense if minification is disabled""" |
| |
| MAX_THREADS = 40 |
| |
| |
| def parse_options(): |
| result = optparse.OptionParser(usage=USAGE) |
| result.add_option('--no-build', |
| help='Run without building first', |
| default=False, |
| action='store_true') |
| result.add_option('--temp', |
| help='Temporary directory to store extracted classes in') |
| result.add_option( |
| '--use_code_size', |
| help= |
| 'Use the size of code segments instead of the full size of the dex.', |
| default=False, |
| action='store_true') |
| result.add_option( |
| '--ignore_debug_info', |
| help='Do not include debug info in the comparison.', |
| default=False, |
| action='store_true') |
| result.add_option( |
| '--report', help='Print comparison to this location instead of stdout') |
| return result.parse_args() |
| |
| |
| def extract_apk(apk, output): |
| if os.path.exists(output): |
| shutil.rmtree(output) |
| zipfile.ZipFile(apk).extractall(output) |
| with utils.ChangedWorkingDirectory(output): |
| dex = glob.glob('*.dex') |
| return [os.path.join(output, dexfile) for dexfile in dex] |
| |
| |
| def ensure_exists(files): |
| for f in files: |
| if not os.path.exists(f): |
| raise Exception('%s does not exist' % f) |
| |
| |
| def extract_classes(input, output, options): |
| if os.path.exists(output): |
| shutil.rmtree(output) |
| os.makedirs(output) |
| args = ['--file-per-class', '--output', output] |
| if options.no_build: |
| args.extend(['--no-build']) |
| args.extend(input) |
| extra_args = [] |
| if options.ignore_debug_info: |
| extra_args.append('-Dcom.android.tools.r8.nullOutDebugInfo=1') |
| if toolhelper.run('d8', args, extra_args=extra_args) != 0: |
| raise Exception('Failed running d8') |
| |
| |
| def get_code_size(path): |
| segments = toolhelper.run('dexsegments', [path], |
| build=False, |
| return_stdout=True) |
| for line in segments.splitlines(): |
| if 'Code' in line: |
| # The code size line looks like: |
| # - Code: 264 / 4 |
| splits = line.split(' ') |
| return int(splits[3]) |
| # Some classes has no code. |
| return 0 |
| |
| |
| class FileInfo: |
| |
| def __init__(self, path, root): |
| self.path = path |
| self.full_path = os.path.join(root, path) |
| |
| def __eq__(self, other): |
| return self.full_path == other.full_path |
| |
| def set_size(self, use_code_size): |
| if use_code_size: |
| self.size = get_code_size(self.full_path) |
| else: |
| self.size = os.path.getsize(self.full_path) |
| |
| |
| def generate_file_info(path, options): |
| file_info_map = {} |
| with utils.ChangedWorkingDirectory(path): |
| for root, dirs, files in os.walk('.'): |
| for f in files: |
| assert f.endswith('dex') |
| file_path = os.path.join(root, f) |
| entry = FileInfo(file_path, path) |
| if not options.use_code_size: |
| entry.set_size(False) |
| file_info_map[file_path] = entry |
| threads = [] |
| file_infos = list(file_info_map.values()) if options.use_code_size else [] |
| while len(file_infos) > 0 or len(threads) > 0: |
| for t in threads: |
| if not t.is_alive(): |
| threads.remove(t) |
| # sleep |
| if len(threads) == MAX_THREADS or len(file_infos) == 0: |
| time.sleep(0.5) |
| while len(threads) < MAX_THREADS and len(file_infos) > 0: |
| info = file_infos.pop() |
| print('Added %s for size calculation' % info.full_path) |
| t = threading.Thread(target=info.set_size, |
| args=(options.use_code_size,)) |
| threads.append(t) |
| t.start() |
| print('Missing %s files, threads=%s ' % (len(file_infos), len(threads))) |
| |
| return file_info_map |
| |
| |
| def print_info(app, app_files, only_in_app, bigger_in_app, output): |
| output.write('Only in %s\n' % app) |
| only_app_sorted = sorted(only_in_app, |
| key=lambda a: app_files[a].size, |
| reverse=True) |
| output.write('\n'.join( |
| [' %s %s bytes' % (x, app_files[x].size) for x in only_app_sorted])) |
| output.write('\n\n') |
| output.write('Bigger in %s\n' % app) |
| # Sort by the percentage diff compared to size |
| percent = lambda a: (0.0 + bigger_in_app.get(a)) / app_files.get(a |
| ).size * 100 |
| for bigger in sorted(bigger_in_app, key=percent, reverse=True): |
| output.write(' {0:.3f}% {1} bytes {2}\n'.format( |
| percent(bigger), bigger_in_app[bigger], bigger)) |
| output.write('\n\n') |
| |
| |
| def compare(app1_classes_dir, app2_classes_dir, app1, app2, options): |
| app1_files = generate_file_info(app1_classes_dir, options) |
| app2_files = generate_file_info(app2_classes_dir, options) |
| only_in_app1 = [k for k in app1_files if k not in app2_files] |
| only_in_app2 = [k for k in app2_files if k not in app1_files] |
| in_both = [k for k in app2_files if k in app1_files] |
| assert len(app1_files) == len(only_in_app1) + len(in_both) |
| assert len(app2_files) == len(only_in_app2) + len(in_both) |
| bigger_in_app1 = {} |
| bigger_in_app2 = {} |
| same_size = [] |
| for f in in_both: |
| app1_entry = app1_files[f] |
| app2_entry = app2_files[f] |
| if app1_entry.size > app2_entry.size: |
| bigger_in_app1[f] = app1_entry.size - app2_entry.size |
| elif app2_entry.size > app1_entry.size: |
| bigger_in_app2[f] = app2_entry.size - app1_entry.size |
| else: |
| same_size.append(f) |
| output = open(options.report, 'w') if options.report else sys.stdout |
| print_info(app1, app1_files, only_in_app1, bigger_in_app1, output) |
| print_info(app2, app2_files, only_in_app2, bigger_in_app2, output) |
| output.write('Same size\n') |
| output.write('\n'.join([' %s' % x for x in same_size])) |
| if options.report: |
| output.close() |
| |
| |
| def Main(): |
| (options, args) = parse_options() |
| if len(args) != 2: |
| print(args) |
| print('Takes exactly two arguments, the two apps to compare') |
| return 1 |
| app1 = args[0] |
| app2 = args[1] |
| ensure_exists([app1, app2]) |
| with utils.TempDir() as temporary: |
| # If a temp dir is passed in, use that instead of the generated temporary |
| output = options.temp if options.temp else temporary |
| ensure_exists([output]) |
| app1_input = [app1] |
| app2_input = [app2] |
| if app1.endswith('apk'): |
| app1_input = extract_apk(app1, os.path.join(output, 'app1')) |
| if app2.endswith('apk'): |
| app2_input = extract_apk(app2, os.path.join(output, 'app2')) |
| app1_classes_dir = os.path.join(output, 'app1_classes') |
| app2_classes_dir = os.path.join(output, 'app2_classes') |
| |
| extract_classes(app1_input, app1_classes_dir, options) |
| extract_classes(app2_input, app2_classes_dir, options) |
| compare(app1_classes_dir, app2_classes_dir, app1, app2, options) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(Main()) |