From 83b13eb07d9c0c538961a078a22d75f12abcc38e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 7 Jan 2025 10:17:49 +0800 Subject: [PATCH] Resolve remaining iOS widget memory leaks. --- changes/2849.bugfix.rst | 1 + iOS/src/toga_iOS/constraints.py | 13 +++++++++---- iOS/src/toga_iOS/container.py | 5 +++++ iOS/src/toga_iOS/widgets/box.py | 5 +---- iOS/src/toga_iOS/widgets/selection.py | 8 +++++++- testbed/tests/widgets/test_box.py | 2 +- testbed/tests/widgets/test_imageview.py | 2 +- testbed/tests/widgets/test_optioncontainer.py | 2 +- testbed/tests/widgets/test_scrollcontainer.py | 2 +- testbed/tests/widgets/test_selection.py | 2 +- 10 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 changes/2849.bugfix.rst diff --git a/changes/2849.bugfix.rst b/changes/2849.bugfix.rst new file mode 100644 index 0000000000..95b367505c --- /dev/null +++ b/changes/2849.bugfix.rst @@ -0,0 +1 @@ +Widgets on the iOS backend no longer leak memory when destroyed. diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index d6df59290f..dbf9ddcace 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -34,10 +34,15 @@ def __del__(self): # pragma: nocover def _remove_constraints(self): if self.container: # print(f"Remove constraints for {self.widget} in {self.container}") - self.container.native.removeConstraint(self.width_constraint) - self.container.native.removeConstraint(self.height_constraint) - self.container.native.removeConstraint(self.left_constraint) - self.container.native.removeConstraint(self.top_constraint) + # Due to the unpredictability of garbage collection, it's possible for + # the native object of the window's container to be deleted on the ObjC + # side before the constraints for the window have been removed. Protect + # against this possibility. + if self.container.native: + self.container.native.removeConstraint(self.width_constraint) + self.container.native.removeConstraint(self.height_constraint) + self.container.native.removeConstraint(self.left_constraint) + self.container.native.removeConstraint(self.top_constraint) @property def container(self): diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index f9b452cf26..b67fad44fc 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -72,6 +72,11 @@ def __init__(self, content=None, layout_native=None, on_refresh=None): self.layout_native = self.native if layout_native is None else layout_native + def __del__(self): + # Mark the contained native object as explicitly None so that the + # constraints know the object has been deleted. + self.native = None + @property def width(self): return self.layout_native.bounds.size.width diff --git a/iOS/src/toga_iOS/widgets/box.py b/iOS/src/toga_iOS/widgets/box.py index 7a110c303c..82f53a64e9 100644 --- a/iOS/src/toga_iOS/widgets/box.py +++ b/iOS/src/toga_iOS/widgets/box.py @@ -12,10 +12,7 @@ class TogaView(UIView): class Box(Widget): def create(self): - # This should be a TogaView - but making it a TogaView causes segfaults when - # content is added and removed from containers like OptionContainer and - # ScrollContainer. - self.native = UIView.alloc().init() + self.native = TogaView.alloc().init() self.native.interface = self.interface self.native.impl = self diff --git a/iOS/src/toga_iOS/widgets/selection.py b/iOS/src/toga_iOS/widgets/selection.py index 02651d50c4..623fb5242a 100644 --- a/iOS/src/toga_iOS/widgets/selection.py +++ b/iOS/src/toga_iOS/widgets/selection.py @@ -12,9 +12,15 @@ from toga_iOS.widgets.base import Widget +class TogaBaseTextField(UITextField): + interface = objc_property(object, weak=True) + impl = objc_property(object, weak=True) + + class TogaPickerView(UIPickerView): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) + native = objc_property(object, weak=True) @objc_method def numberOfComponentsInPickerView_(self, pickerView) -> int: @@ -53,7 +59,7 @@ def pickerView_didSelectRow_inComponent_( class Selection(Widget): def create(self): - self.native = UITextField.alloc().init() + self.native = TogaBaseTextField.alloc().init() self.native.interface = self.interface self.native.impl = self self.native.tintColor = UIColor.clearColor diff --git a/testbed/tests/widgets/test_box.py b/testbed/tests/widgets/test_box.py index 13370d2b53..b2d263e1c3 100644 --- a/testbed/tests/widgets/test_box.py +++ b/testbed/tests/widgets/test_box.py @@ -18,4 +18,4 @@ async def widget(): return toga.Box(style=Pack(width=100, height=200)) -test_cleanup = build_cleanup_test(toga.Box, xfail_platforms=("iOS",)) +test_cleanup = build_cleanup_test(toga.Box) diff --git a/testbed/tests/widgets/test_imageview.py b/testbed/tests/widgets/test_imageview.py index 31e2c209ca..b9431778a4 100644 --- a/testbed/tests/widgets/test_imageview.py +++ b/testbed/tests/widgets/test_imageview.py @@ -19,7 +19,7 @@ async def widget(): test_cleanup = build_cleanup_test( - toga.ImageView, kwargs={"image": "resources/sample.png"}, xfail_platforms=("iOS",) + toga.ImageView, kwargs={"image": "resources/sample.png"} ) diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index 906e97f216..8e0c8cbd93 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -76,7 +76,7 @@ async def widget(content1, content2, content3, on_select_handler): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.OptionContainer. This would raise a runtime error on Windows. lambda: toga.OptionContainer(content=[("Tab 1", toga.Box())]), - xfail_platforms=("android", "iOS", "linux"), + xfail_platforms=("android", "linux"), ) diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 4833513ae1..a8c9f262d3 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -77,7 +77,7 @@ async def widget(content, on_scroll): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.ScrollContainer. This would raise a runtime error on Windows. lambda: toga.ScrollContainer(content=toga.Box()), - xfail_platforms=("android", "iOS", "linux"), + xfail_platforms=("android", "linux"), ) diff --git a/testbed/tests/widgets/test_selection.py b/testbed/tests/widgets/test_selection.py index a5eb5a671e..102f0d1a62 100644 --- a/testbed/tests/widgets/test_selection.py +++ b/testbed/tests/widgets/test_selection.py @@ -55,7 +55,7 @@ def verify_vertical_text_align(): test_cleanup = build_cleanup_test( toga.Selection, kwargs={"items": ["first", "second", "third"]}, - xfail_platforms=("android", "iOS", "windows"), + xfail_platforms=("android", "windows"), )