blob: ad750243c2099284f9161455110d5200f9fb5c01 [file] [log] [blame] [edit]
#!/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('--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)
if toolhelper.run('d8', args) is not 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 = 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) is not 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())