blob: ad750243c2099284f9161455110d5200f9fb5c01 [file] [log] [blame]
Ian Zernydcb172e2022-02-22 15:36:45 +01001#!/usr/bin/env python3
Rico Wind58d01432018-09-13 14:07:31 +02002# Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
3# for details. All rights reserved. Use of this source code is governed by a
4# BSD-style license that can be found in the LICENSE file.
5
6# Script for checking impact of a change by comparing the sizes of generated
7# classes in an apk.
8
9import glob
10import optparse
11import os
12import shutil
13import sys
Rico Wind97daeb72019-01-22 09:25:09 +010014import threading
15import time
Ian Zernye92325b2020-03-13 13:29:27 +010016import zipfile
17
Rico Wind58d01432018-09-13 14:07:31 +020018import toolhelper
19import utils
Rico Wind58d01432018-09-13 14:07:31 +020020
21USAGE = """%prog [options] app1 app2
22 NOTE: This only makes sense if minification is disabled"""
23
Rico Wind97daeb72019-01-22 09:25:09 +010024MAX_THREADS=40
25
Rico Wind58d01432018-09-13 14:07:31 +020026def parse_options():
27 result = optparse.OptionParser(usage=USAGE)
Morten Krogh-Jespersenec9bbe42022-09-19 15:27:21 +020028 result.add_option('--no-build',
29 help='Run without building first',
30 default=False,
31 action='store_true')
Rico Wind58d01432018-09-13 14:07:31 +020032 result.add_option('--temp',
33 help='Temporary directory to store extracted classes in')
Rico Winde2852342019-01-16 14:42:18 +010034 result.add_option('--use_code_size',
35 help='Use the size of code segments instead of the full size of the dex.',
36 default=False, action='store_true')
Rico Wind58d01432018-09-13 14:07:31 +020037 result.add_option('--report',
38 help='Print comparison to this location instead of stdout')
39 return result.parse_args()
40
41def extract_apk(apk, output):
42 if os.path.exists(output):
43 shutil.rmtree(output)
44 zipfile.ZipFile(apk).extractall(output)
45 with utils.ChangedWorkingDirectory(output):
46 dex = glob.glob('*.dex')
47 return [os.path.join(output, dexfile) for dexfile in dex]
48
49def ensure_exists(files):
50 for f in files:
51 if not os.path.exists(f):
Rico Winde572a802021-08-18 12:07:38 +020052 raise Exception('%s does not exist' % f)
Rico Wind58d01432018-09-13 14:07:31 +020053
Morten Krogh-Jespersenec9bbe42022-09-19 15:27:21 +020054def extract_classes(input, output, options):
Rico Wind58d01432018-09-13 14:07:31 +020055 if os.path.exists(output):
56 shutil.rmtree(output)
57 os.makedirs(output)
58 args = ['--file-per-class',
59 '--output', output]
Morten Krogh-Jespersenec9bbe42022-09-19 15:27:21 +020060 if options.no_build:
61 args.extend(['--no-build'])
Rico Wind58d01432018-09-13 14:07:31 +020062 args.extend(input)
63 if toolhelper.run('d8', args) is not 0:
64 raise Exception('Failed running d8')
65
Rico Winde2852342019-01-16 14:42:18 +010066def get_code_size(path):
67 segments = toolhelper.run('dexsegments',
68 [path],
69 build=False,
70 return_stdout=True)
71 for line in segments.splitlines():
72 if 'Code' in line:
73 # The code size line looks like:
74 # - Code: 264 / 4
75 splits = line.split(' ')
76 return int(splits[3])
Rico Windc8917962019-01-17 11:15:34 +010077 # Some classes has no code.
78 return 0
Rico Winde2852342019-01-16 14:42:18 +010079
Rico Wind58d01432018-09-13 14:07:31 +020080class FileInfo:
Rico Wind97daeb72019-01-22 09:25:09 +010081 def __init__(self, path, root):
Rico Wind58d01432018-09-13 14:07:31 +020082 self.path = path
83 self.full_path = os.path.join(root, path)
Rico Wind97daeb72019-01-22 09:25:09 +010084
85 def __eq__(self, other):
86 return self.full_path == other.full_path
87
88 def set_size(self, use_code_size):
Rico Winde2852342019-01-16 14:42:18 +010089 if use_code_size:
90 self.size = get_code_size(self.full_path)
91 else:
92 self.size = os.path.getsize(self.full_path)
Rico Wind58d01432018-09-13 14:07:31 +020093
Rico Winde2852342019-01-16 14:42:18 +010094def generate_file_info(path, options):
Rico Wind58d01432018-09-13 14:07:31 +020095 file_info_map = {}
96 with utils.ChangedWorkingDirectory(path):
97 for root, dirs, files in os.walk('.'):
98 for f in files:
99 assert f.endswith('dex')
100 file_path = os.path.join(root, f)
Rico Wind97daeb72019-01-22 09:25:09 +0100101 entry = FileInfo(file_path, path)
102 if not options.use_code_size:
Rico Wind8fcb3152019-06-13 14:38:19 +0200103 entry.set_size(False)
Rico Wind58d01432018-09-13 14:07:31 +0200104 file_info_map[file_path] = entry
Rico Wind97daeb72019-01-22 09:25:09 +0100105 threads = []
106 file_infos = file_info_map.values() if options.use_code_size else []
107 while len(file_infos) > 0 or len(threads)> 0:
108 for t in threads:
109 if not t.is_alive():
110 threads.remove(t)
111 # sleep
112 if len(threads) == MAX_THREADS or len(file_infos) == 0:
113 time.sleep(0.5)
114 while len(threads) < MAX_THREADS and len(file_infos) > 0:
115 info = file_infos.pop()
116 print('Added %s for size calculation' % info.full_path)
117 t = threading.Thread(target=info.set_size, args=(options.use_code_size,))
118 threads.append(t)
119 t.start()
120 print('Missing %s files, threads=%s ' % (len(file_infos), len(threads)))
121
Rico Wind58d01432018-09-13 14:07:31 +0200122 return file_info_map
123
124def print_info(app, app_files, only_in_app, bigger_in_app, output):
125 output.write('Only in %s\n' % app)
126 only_app_sorted = sorted(only_in_app,
127 key=lambda a: app_files[a].size,
128 reverse=True)
129 output.write('\n'.join([' %s %s bytes' %
130 (x, app_files[x].size) for x in only_app_sorted]))
131 output.write('\n\n')
132 output.write('Bigger in %s\n' % app)
133 # Sort by the percentage diff compared to size
134 percent = lambda a: (0.0 + bigger_in_app.get(a))/app_files.get(a).size * 100
135 for bigger in sorted(bigger_in_app, key=percent, reverse=True):
136 output.write(' {0:.3f}% {1} bytes {2}\n'.format(percent(bigger),
137 bigger_in_app[bigger],
138 bigger))
139 output.write('\n\n')
140
141
Rico Winde2852342019-01-16 14:42:18 +0100142def compare(app1_classes_dir, app2_classes_dir, app1, app2, options):
143 app1_files = generate_file_info(app1_classes_dir, options)
144 app2_files = generate_file_info(app2_classes_dir, options)
Rico Wind58d01432018-09-13 14:07:31 +0200145 only_in_app1 = [k for k in app1_files if k not in app2_files]
146 only_in_app2 = [k for k in app2_files if k not in app1_files]
147 in_both = [k for k in app2_files if k in app1_files]
148 assert len(app1_files) == len(only_in_app1) + len(in_both)
149 assert len(app2_files) == len(only_in_app2) + len(in_both)
150 bigger_in_app1 = {}
151 bigger_in_app2 = {}
152 same_size = []
153 for f in in_both:
154 app1_entry = app1_files[f]
155 app2_entry = app2_files[f]
156 if app1_entry.size > app2_entry.size:
157 bigger_in_app1[f] = app1_entry.size - app2_entry.size
158 elif app2_entry.size > app1_entry.size:
159 bigger_in_app2[f] = app2_entry.size - app1_entry.size
160 else:
161 same_size.append(f)
Rico Winde2852342019-01-16 14:42:18 +0100162 output = open(options.report, 'w') if options.report else sys.stdout
Rico Wind58d01432018-09-13 14:07:31 +0200163 print_info(app1, app1_files, only_in_app1, bigger_in_app1, output)
164 print_info(app2, app2_files, only_in_app2, bigger_in_app2, output)
165 output.write('Same size\n')
166 output.write('\n'.join([' %s' % x for x in same_size]))
Rico Winde2852342019-01-16 14:42:18 +0100167 if options.report:
Rico Wind58d01432018-09-13 14:07:31 +0200168 output.close()
169
170def Main():
171 (options, args) = parse_options()
172 if len(args) is not 2:
Rico Wind866bfe62022-05-17 09:22:44 +0000173 print(args)
Rico Wind58d01432018-09-13 14:07:31 +0200174 print('Takes exactly two arguments, the two apps to compare')
175 return 1
176 app1 = args[0]
177 app2 = args[1]
178 ensure_exists([app1, app2])
179 with utils.TempDir() as temporary:
180 # If a temp dir is passed in, use that instead of the generated temporary
181 output = options.temp if options.temp else temporary
182 ensure_exists([output])
183 app1_input = [app1]
184 app2_input = [app2]
185 if app1.endswith('apk'):
186 app1_input = extract_apk(app1, os.path.join(output, 'app1'))
187 if app2.endswith('apk'):
188 app2_input = extract_apk(app2, os.path.join(output, 'app2'))
189 app1_classes_dir = os.path.join(output, 'app1_classes')
190 app2_classes_dir = os.path.join(output, 'app2_classes')
191
Morten Krogh-Jespersenec9bbe42022-09-19 15:27:21 +0200192 extract_classes(app1_input, app1_classes_dir, options)
193 extract_classes(app2_input, app2_classes_dir, options)
Rico Winde2852342019-01-16 14:42:18 +0100194 compare(app1_classes_dir, app2_classes_dir, app1, app2, options)
Rico Wind58d01432018-09-13 14:07:31 +0200195
196if __name__ == '__main__':
197 sys.exit(Main())