blob: 0a0bcf3941050a7f12d160805601e5da0b8b153c [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
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020024MAX_THREADS = 40
25
Rico Wind97daeb72019-01-22 09:25:09 +010026
Rico Wind58d01432018-09-13 14:07:31 +020027def parse_options():
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020028 result = optparse.OptionParser(usage=USAGE)
29 result.add_option('--no-build',
30 help='Run without building first',
31 default=False,
32 action='store_true')
33 result.add_option('--temp',
34 help='Temporary directory to store extracted classes in')
35 result.add_option(
36 '--use_code_size',
37 help=
38 'Use the size of code segments instead of the full size of the dex.',
39 default=False,
40 action='store_true')
41 result.add_option(
Rico Winda1129742024-04-09 11:05:06 +020042 '--ignore_debug_info',
43 help='Do not include debug info in the comparison.',
44 default=False,
45 action='store_true')
46 result.add_option(
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020047 '--report', help='Print comparison to this location instead of stdout')
48 return result.parse_args()
49
Rico Wind58d01432018-09-13 14:07:31 +020050
51def extract_apk(apk, output):
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020052 if os.path.exists(output):
53 shutil.rmtree(output)
54 zipfile.ZipFile(apk).extractall(output)
55 with utils.ChangedWorkingDirectory(output):
56 dex = glob.glob('*.dex')
57 return [os.path.join(output, dexfile) for dexfile in dex]
58
Rico Wind58d01432018-09-13 14:07:31 +020059
60def ensure_exists(files):
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020061 for f in files:
62 if not os.path.exists(f):
63 raise Exception('%s does not exist' % f)
64
Rico Wind58d01432018-09-13 14:07:31 +020065
Morten Krogh-Jespersenec9bbe42022-09-19 15:27:21 +020066def extract_classes(input, output, options):
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020067 if os.path.exists(output):
68 shutil.rmtree(output)
69 os.makedirs(output)
70 args = ['--file-per-class', '--output', output]
71 if options.no_build:
72 args.extend(['--no-build'])
73 args.extend(input)
Rico Winda1129742024-04-09 11:05:06 +020074 extra_args = []
75 if options.ignore_debug_info:
76 extra_args.append('-Dcom.android.tools.r8.nullOutDebugInfo=1')
Ian Zerny6a2bb9d2024-05-17 09:57:28 +020077 if toolhelper.run('d8', args, extra_args=extra_args) != 0:
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020078 raise Exception('Failed running d8')
79
Rico Wind58d01432018-09-13 14:07:31 +020080
Rico Winde2852342019-01-16 14:42:18 +010081def get_code_size(path):
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020082 segments = toolhelper.run('dexsegments', [path],
83 build=False,
84 return_stdout=True)
85 for line in segments.splitlines():
86 if 'Code' in line:
87 # The code size line looks like:
88 # - Code: 264 / 4
89 splits = line.split(' ')
90 return int(splits[3])
91 # Some classes has no code.
92 return 0
93
Rico Winde2852342019-01-16 14:42:18 +010094
Rico Wind58d01432018-09-13 14:07:31 +020095class FileInfo:
Rico Wind97daeb72019-01-22 09:25:09 +010096
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +020097 def __init__(self, path, root):
98 self.path = path
99 self.full_path = os.path.join(root, path)
Rico Wind97daeb72019-01-22 09:25:09 +0100100
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200101 def __eq__(self, other):
102 return self.full_path == other.full_path
103
104 def set_size(self, use_code_size):
105 if use_code_size:
106 self.size = get_code_size(self.full_path)
107 else:
108 self.size = os.path.getsize(self.full_path)
109
Rico Wind58d01432018-09-13 14:07:31 +0200110
Rico Winde2852342019-01-16 14:42:18 +0100111def generate_file_info(path, options):
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200112 file_info_map = {}
113 with utils.ChangedWorkingDirectory(path):
114 for root, dirs, files in os.walk('.'):
115 for f in files:
116 assert f.endswith('dex')
117 file_path = os.path.join(root, f)
118 entry = FileInfo(file_path, path)
119 if not options.use_code_size:
120 entry.set_size(False)
121 file_info_map[file_path] = entry
122 threads = []
Rico Windfdf6df02024-04-08 12:58:51 +0200123 file_infos = list(file_info_map.values()) if options.use_code_size else []
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200124 while len(file_infos) > 0 or len(threads) > 0:
125 for t in threads:
126 if not t.is_alive():
127 threads.remove(t)
128 # sleep
129 if len(threads) == MAX_THREADS or len(file_infos) == 0:
130 time.sleep(0.5)
131 while len(threads) < MAX_THREADS and len(file_infos) > 0:
132 info = file_infos.pop()
133 print('Added %s for size calculation' % info.full_path)
134 t = threading.Thread(target=info.set_size,
135 args=(options.use_code_size,))
136 threads.append(t)
137 t.start()
138 print('Missing %s files, threads=%s ' % (len(file_infos), len(threads)))
Rico Wind97daeb72019-01-22 09:25:09 +0100139
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200140 return file_info_map
141
Rico Wind58d01432018-09-13 14:07:31 +0200142
143def print_info(app, app_files, only_in_app, bigger_in_app, output):
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200144 output.write('Only in %s\n' % app)
145 only_app_sorted = sorted(only_in_app,
146 key=lambda a: app_files[a].size,
147 reverse=True)
148 output.write('\n'.join(
149 [' %s %s bytes' % (x, app_files[x].size) for x in only_app_sorted]))
150 output.write('\n\n')
151 output.write('Bigger in %s\n' % app)
152 # Sort by the percentage diff compared to size
153 percent = lambda a: (0.0 + bigger_in_app.get(a)) / app_files.get(a
154 ).size * 100
155 for bigger in sorted(bigger_in_app, key=percent, reverse=True):
156 output.write(' {0:.3f}% {1} bytes {2}\n'.format(
157 percent(bigger), bigger_in_app[bigger], bigger))
158 output.write('\n\n')
Rico Wind58d01432018-09-13 14:07:31 +0200159
160
Rico Winde2852342019-01-16 14:42:18 +0100161def compare(app1_classes_dir, app2_classes_dir, app1, app2, options):
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200162 app1_files = generate_file_info(app1_classes_dir, options)
163 app2_files = generate_file_info(app2_classes_dir, options)
164 only_in_app1 = [k for k in app1_files if k not in app2_files]
165 only_in_app2 = [k for k in app2_files if k not in app1_files]
166 in_both = [k for k in app2_files if k in app1_files]
167 assert len(app1_files) == len(only_in_app1) + len(in_both)
168 assert len(app2_files) == len(only_in_app2) + len(in_both)
169 bigger_in_app1 = {}
170 bigger_in_app2 = {}
171 same_size = []
172 for f in in_both:
173 app1_entry = app1_files[f]
174 app2_entry = app2_files[f]
175 if app1_entry.size > app2_entry.size:
176 bigger_in_app1[f] = app1_entry.size - app2_entry.size
177 elif app2_entry.size > app1_entry.size:
178 bigger_in_app2[f] = app2_entry.size - app1_entry.size
179 else:
180 same_size.append(f)
181 output = open(options.report, 'w') if options.report else sys.stdout
182 print_info(app1, app1_files, only_in_app1, bigger_in_app1, output)
183 print_info(app2, app2_files, only_in_app2, bigger_in_app2, output)
184 output.write('Same size\n')
185 output.write('\n'.join([' %s' % x for x in same_size]))
186 if options.report:
187 output.close()
188
Rico Wind58d01432018-09-13 14:07:31 +0200189
190def Main():
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200191 (options, args) = parse_options()
Ian Zerny6a2bb9d2024-05-17 09:57:28 +0200192 if len(args) != 2:
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200193 print(args)
194 print('Takes exactly two arguments, the two apps to compare')
195 return 1
196 app1 = args[0]
197 app2 = args[1]
198 ensure_exists([app1, app2])
199 with utils.TempDir() as temporary:
200 # If a temp dir is passed in, use that instead of the generated temporary
201 output = options.temp if options.temp else temporary
202 ensure_exists([output])
203 app1_input = [app1]
204 app2_input = [app2]
205 if app1.endswith('apk'):
206 app1_input = extract_apk(app1, os.path.join(output, 'app1'))
207 if app2.endswith('apk'):
208 app2_input = extract_apk(app2, os.path.join(output, 'app2'))
209 app1_classes_dir = os.path.join(output, 'app1_classes')
210 app2_classes_dir = os.path.join(output, 'app2_classes')
Rico Wind58d01432018-09-13 14:07:31 +0200211
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200212 extract_classes(app1_input, app1_classes_dir, options)
213 extract_classes(app2_input, app2_classes_dir, options)
214 compare(app1_classes_dir, app2_classes_dir, app1, app2, options)
215
Rico Wind58d01432018-09-13 14:07:31 +0200216
217if __name__ == '__main__':
Christoffer Quist Adamsen2434a4d2023-10-16 11:29:03 +0200218 sys.exit(Main())